Files
superset2/superset/versioning/baseline/children.py
Mike Bridge a899e1db41 feat(versioning): entity-version base infrastructure (gated off, dark launch) (#41176)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:57:05 -07:00

213 lines
8.1 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-entity child-baseline handlers.
After a parent baseline row lands in :mod:`.insertion`, this module's
handlers write the parent's child baselines under the same transaction
id. The dispatch table :data:`CHILD_BASELINE_HANDLERS` is keyed on
the parent class name (avoids an import-cycle with the entity modules,
which can't be loaded at app-init time).
The dataset handler baselines :class:`TableColumn` and
:class:`SqlMetric` children. The dashboard handler baselines the
``dashboard_slices`` M2M membership *and* synthesizes
``operation_type=0`` rows in ``slices_version`` for attached slices
that have no prior shadow — without those slice-side baselines,
Continuum's M2M revert query returns empty.
Leaf-level helpers (:func:`_insert_child_baseline_rows`,
:func:`_baseline_attached_slices`,
:func:`_insert_synthetic_slice_baseline`) live here too — they're
shared between the two parent-specific handlers.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import sqlalchemy as sa
from sqlalchemy.orm import Session
from superset.versioning.baseline.shadow import insert_baseline_shadow_row
def _baseline_dataset_children(session: Session, dataset: Any, tx_id: int) -> None:
"""Baseline a dataset's ``TableColumn`` and ``SqlMetric`` children
under the dataset's baseline tx.
"""
# pylint: disable=import-outside-toplevel
from sqlalchemy_continuum import version_class
from superset.connectors.sqla.models import SqlMetric, TableColumn
for child_cls in (TableColumn, SqlMetric):
_insert_child_baseline_rows(
session,
dataset,
child_cls.__table__,
version_class(child_cls).__table__,
"table_id",
tx_id,
)
def _baseline_dashboard_children(session: Session, dashboard: Any, tx_id: int) -> None:
"""Baseline a dashboard's ``dashboard_slices`` M2M plus synthesize
``operation_type=0`` rows in ``slices_version`` for attached slices
with no prior shadow.
Continuum's M2M version-side relationship for ``Dashboard.slices``
joins through both ``dashboard_slices_version`` AND
``slices_version``: the second exists clause filters slices by
"latest slices_version row with tx <= dashboard.tx". If a slice
has no slices_version rows at all, that join produces no match
and ``version_obj.slices`` returns empty — leaving the dashboard
restore with no slices to append. The synthetic slice baseline at
this dashboard's tx gives the M2M query a slice version it can match.
Doesn't try to be clever about slices shared across dashboards: a
slice is baselined at this dashboard's tx_id only when it has no
shadow rows at all. If a later dashboard baseline references the
same slice, this baseline (now at lower tx) is still found by
that dashboard's restore. The reverse — a dashboard baselined
AFTER the slice was first baselined under another dashboard at
a higher tx — is a residual gap deferred to a future fix.
"""
metadata = type(dashboard).__table__.metadata
live_tbl = metadata.tables.get("dashboard_slices")
shadow_tbl = metadata.tables.get("dashboard_slices_version")
if live_tbl is None or shadow_tbl is None:
return
_insert_child_baseline_rows(
session, dashboard, live_tbl, shadow_tbl, "dashboard_id", tx_id
)
_baseline_attached_slices(session, dashboard, live_tbl, tx_id)
# Dispatch table keyed by parent CLASS NAME rather than class, to avoid
# the import-cycle between baseline.py (loaded at app init) and the
# entity modules. The class-name string is set once at app start by
# the model definitions — typo-prone if extended. Declared after the
# handlers it references because module-level dict literals evaluate
# at import time and need the names already bound.
_ChildBaselineHandler = Callable[[Session, Any, int], None]
CHILD_BASELINE_HANDLERS: dict[str, _ChildBaselineHandler] = {
"SqlaTable": _baseline_dataset_children,
"Dashboard": _baseline_dashboard_children,
}
def _insert_child_baseline_rows(
session: Session,
parent_obj: Any,
child_table: sa.Table,
child_version_table: sa.Table,
fk_column_name: str,
tx_id: int,
) -> None:
"""Synthesize ``operation_type=0`` shadow rows for every live child of
*parent_obj* under transaction id *tx_id*.
Parallels :func:`~superset.versioning.baseline.insertion._insert_baseline_row`
but iterates over child rows. Used to give Continuum's ``Reverter``
baseline data for children of pre-existing parents (children that
predate this commit have no shadow rows otherwise, so Reverter
would treat them as "deleted at the target tx" and try to remove
them on revert — the ADR-004 Failure 1 reproduction scenario).
:param child_table: the live child SQLAlchemy ``Table`` (e.g.
``TableColumn.__table__`` or the bare ``dashboard_slices`` association)
:param child_version_table: the corresponding Continuum shadow ``Table``
:param fk_column_name: column on *child_table* that points to the parent
(e.g. ``"table_id"`` for ``TableColumn``, ``"dashboard_id"`` for
``dashboard_slices``)
"""
conn = session.connection()
fk_col = getattr(child_table.c, fk_column_name)
rows = (
conn.execute(sa.select(child_table).where(fk_col == parent_obj.id))
.mappings()
.all()
)
if not rows:
return
for row in rows:
insert_baseline_shadow_row(conn, child_version_table, row, tx_id)
def _baseline_attached_slices(
session: Session, dashboard: Any, live_tbl: sa.Table, tx_id: int
) -> None:
"""Insert ``operation_type=0`` rows in ``slices_version`` for each
slice attached to *dashboard* that has no shadow row yet.
Batched: one membership SELECT, one existing-shadow SELECT, one live
SELECT for the missing slices. Per-slice work happens only on
``_insert_synthetic_slice_baseline``. The previous per-slice
``COUNT(*)`` + ``SELECT`` pattern was O(N) round-trips and surfaced
as a measurable first-save hotspot on dashboards with many charts.
"""
# pylint: disable=import-outside-toplevel
from sqlalchemy_continuum import version_class
from superset.models.slice import Slice
slice_ver_table = version_class(Slice).__table__
slice_table = Slice.__table__
conn = session.connection()
attached_slice_ids = [
r.slice_id
for r in conn.execute(
sa.select(live_tbl.c.slice_id).where(
live_tbl.c.dashboard_id == dashboard.id
)
).all()
]
if not attached_slice_ids:
return
existing_shadow_ids = {
row[0]
for row in conn.execute(
sa.select(slice_ver_table.c.id.distinct()).where(
slice_ver_table.c.id.in_(attached_slice_ids)
)
).all()
}
missing_ids = [sid for sid in attached_slice_ids if sid not in existing_shadow_ids]
if not missing_ids:
return
slice_rows = (
conn.execute(sa.select(slice_table).where(slice_table.c.id.in_(missing_ids)))
.mappings()
.all()
)
for slice_row in slice_rows:
_insert_synthetic_slice_baseline(conn, slice_ver_table, slice_row, tx_id)
def _insert_synthetic_slice_baseline(
conn: Any, slice_ver_table: sa.Table, slice_row: Any, tx_id: int
) -> None:
insert_baseline_shadow_row(conn, slice_ver_table, slice_row, tx_id)