Files
superset2/superset/versioning/activity/scope.py
Mike Bridge caf017bd0b feat(versioning): cross-entity version activity view (#41076)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 11:06:54 -07:00

139 lines
5.6 KiB
Python

# 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: bool = include in ("all", "self")
want_related: bool = 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 and self_start_tx is not None:
scope.extend(_resolve_related_scope(path_kind, path_id, self_start_tx))
return scope
def _resolve_related_scope(
path_kind: str, path_id: int, self_start_tx: int
) -> list[EntityWindows]:
"""Walk the dependency edges from the path entity to its related
entities. Datasets have no transitive layer in V2.
The path entity's incarnation boundary clips every related window so a
reused integer id cannot inherit a hard-deleted predecessor's dependency
history.
"""
if path_kind == "Dashboard":
related: list[EntityWindows] = _resolve_dashboard_scope(path_id)
elif path_kind == "Slice":
related = _resolve_chart_scope(path_id)
else:
return []
incarnation = Window(self_start_tx, None)
clipped: list[EntityWindows] = []
for api_kind, entity_id, windows in related:
overlaps = [
overlap
for window in windows
if (overlap := intersect_windows(incarnation, window)) is not None
]
if overlaps:
clipped.append((api_kind, entity_id, overlaps))
return merge_entity_windows(clipped)
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]))
return merge_entity_windows(scope)
def _resolve_chart_scope(slice_id: int) -> list[EntityWindows]:
"""Datasets the chart pointed at over its full history."""
scope: list[EntityWindows] = []
for dataset_id, window in datasets_used_by_chart(slice_id):
scope.append(("SqlaTable", dataset_id, [window]))
return merge_entity_windows(scope)