Files
superset2/superset/versioning/activity/impact.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

187 lines
7.4 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.
"""Per-record impact computation for the activity DTO.
Only dashboard-path activity records pointing at a ``SqlaTable``
related entity carry an ``impact`` field — the number of charts on
the dashboard at that transaction that were pointing at the dataset.
This module computes that count in a single batched query per
request:
* :func:`collect_impact_pairs` — pulls the distinct
``(dataset_id, transaction_id)`` pairs that need counts.
* :func:`batch_chart_counts` — one SQL query joining
``dashboard_slices_version`` and ``slices_version`` to count
the matching charts validity-strategy-style.
* :func:`impact_for_record` — pure projection from the pre-fetched
counts onto each record (returns ``None`` for non-Dashboard paths
or non-SqlaTable kinds, matching the ``impact`` computation).
Splitting the count batching from the pure projection keeps the SQL
inside one function (the batched read) and the per-record decoration
inside another (no DB).
"""
from __future__ import annotations
from typing import Any
import sqlalchemy as sa
from superset.extensions import db
from superset.versioning.activity.kinds import (
chunked_ids,
ENTITY_ID_CHUNK_SIZE,
TABLE_KIND_TO_API,
)
def collect_impact_pairs(
records: list[dict[str, Any]], path_kind: str
) -> set[tuple[int, int]]:
"""Distinct ``(dataset_id, transaction_id)`` pairs from *records*
that require an impact computation.
Only dashboard-path records whose related entity is a ``SqlaTable``
produce a non-null ``impact`` field; for any other shape this set
is empty and no DB query needs to fire.
"""
if path_kind != "Dashboard":
return set()
return {
(record["entity_id"], record["transaction_id"])
for record in records
if TABLE_KIND_TO_API.get(record["entity_kind"]) == "SqlaTable"
}
def batch_chart_counts(
dashboard_id: int, pairs: set[tuple[int, int]]
) -> dict[tuple[int, int], int]:
"""For every ``(dataset_id, target_tx)`` in *pairs*, count the
distinct charts that were both on *dashboard_id* and pointing at
*dataset_id* at *target_tx*.
One SELECT against ``dashboard_slices_version`` ⨝ ``slices_version``,
pulling the (slice, dataset, validity-window) state for every slice
ever on the dashboard whose dataset matches one of the requested
dataset_ids. The Python loop then applies the validity-strategy
predicate per pair. Replaces the previous N+1 shape that fired one
COUNT per related record.
Returns ``{(dataset_id, target_tx): count}``; pairs whose count
would be zero are omitted so the caller's ``.get(key, 0)`` is
correct.
"""
if not pairs:
return {}
# pylint: disable=import-outside-toplevel
from sqlalchemy_continuum import version_class
from superset.models.slice import Slice
metadata = version_class(Slice).__table__.metadata
m2m_tbl = metadata.tables.get("dashboard_slices_version")
slices_tbl = version_class(Slice).__table__
if m2m_tbl is None:
return {}
dataset_ids: set[int] = {dataset_id for dataset_id, _ in pairs}
# Bound both validity windows to the transaction range the page-set
# needs. Both the attachment (m2m) and the chart→dataset (slice) window
# must straddle a requested target_tx, so a row whose window starts
# after the newest target, or closes at/before the oldest one, can
# never contribute a match. Without this the join multiplies every
# attachment row by the full slice version history — an unbounded cross
# product on dashboards with long-lived, frequently-edited charts.
target_txs: set[int] = {target_tx for _, target_tx in pairs}
min_tx, max_tx = min(target_txs), max(target_txs)
# Chunk the datasource_id IN-clause to stay under SQLite's bind-variable
# floor (a dashboard pointing at very many datasets can exceed it).
rows: list[Any] = []
for chunk in chunked_ids(dataset_ids, ENTITY_ID_CHUNK_SIZE):
stmt = sa.select(
m2m_tbl.c.slice_id,
slices_tbl.c.datasource_id,
m2m_tbl.c.transaction_id.label("m2m_start"),
m2m_tbl.c.end_transaction_id.label("m2m_end"),
slices_tbl.c.transaction_id.label("slice_start"),
slices_tbl.c.end_transaction_id.label("slice_end"),
).where(
m2m_tbl.c.dashboard_id == dashboard_id,
m2m_tbl.c.operation_type != 2,
slices_tbl.c.id == m2m_tbl.c.slice_id,
slices_tbl.c.datasource_id.in_(chunk),
slices_tbl.c.datasource_type == "table",
slices_tbl.c.operation_type != 2,
m2m_tbl.c.transaction_id <= max_tx,
sa.or_(
m2m_tbl.c.end_transaction_id.is_(None),
m2m_tbl.c.end_transaction_id > min_tx,
),
slices_tbl.c.transaction_id <= max_tx,
sa.or_(
slices_tbl.c.end_transaction_id.is_(None),
slices_tbl.c.end_transaction_id > min_tx,
),
)
rows.extend(db.session.connection().execute(stmt).mappings().all())
# For each pair, collect the slice_ids whose two validity windows
# both straddle target_tx. ``set`` dedupes within a pair.
matches: dict[tuple[int, int], set[int]] = {}
pairs_by_dataset: dict[int, list[int]] = {}
for dataset_id, target_tx in pairs:
pairs_by_dataset.setdefault(dataset_id, []).append(target_tx)
for row in rows:
ds_id = row["datasource_id"]
for target_tx in pairs_by_dataset.get(ds_id, ()):
in_m2m = row["m2m_start"] <= target_tx and (
row["m2m_end"] is None or row["m2m_end"] > target_tx
)
in_slice = row["slice_start"] <= target_tx and (
row["slice_end"] is None or row["slice_end"] > target_tx
)
if in_m2m and in_slice:
matches.setdefault((ds_id, target_tx), set()).add(row["slice_id"])
return {pair: len(slice_ids) for pair, slice_ids in matches.items()}
def impact_for_record(
record: dict[str, Any],
path_kind: str,
counts: dict[tuple[int, int], int],
) -> dict[str, int] | None:
"""Synthesize the ``impact`` field for one record using the pre-
fetched *counts* mapping. Pure function — no DB.
For the ``impact`` computation: only
``path=Dashboard`` and ``related=SqlaTable`` shapes carry an
impact; everything else returns ``None``.
"""
api_kind = TABLE_KIND_TO_API.get(record["entity_kind"])
if path_kind != "Dashboard" or api_kind != "SqlaTable":
return None
key = (record["entity_id"], record["transaction_id"])
chart_count = counts.get(key, 0)
if chart_count == 0:
return None
return {"charts": chart_count}