This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 986b648f1ca fix(versioning): resolve dashboard M2M membership by 
attach/detach windows, not end_transaction_id (#44010)
986b648f1ca is described below

commit 986b648f1ca2510584091f139a53b05279182bf0
Author: Mike Bridge <[email protected]>
AuthorDate: Tue Sep 8 18:59:04 2026 -0600

    fix(versioning): resolve dashboard M2M membership by attach/detach windows, 
not end_transaction_id (#44010)
    
    Co-authored-by: Mike Bridge <[email protected]>
    Co-authored-by: Claude Fable 5 <[email protected]>
---
 superset/versioning/activity/impact.py             | 152 ++++++++++++++-------
 superset/versioning/activity/queries.py            |  95 +------------
 superset/versioning/activity/scope.py              |   2 +-
 superset/versioning/activity/windows.py            |  66 +++++++++
 superset/versioning/membership.py                  |  86 ++++++++++++
 superset/versioning/restore.py                     |  50 ++++---
 .../dashboards/version_restore_tests.py            |  70 ++++++++++
 tests/unit_tests/versioning/test_activity.py       | 149 ++++++++++++++++++--
 8 files changed, 487 insertions(+), 183 deletions(-)

diff --git a/superset/versioning/activity/impact.py 
b/superset/versioning/activity/impact.py
index e4684c20b1a..f1c2b22b47e 100644
--- a/superset/versioning/activity/impact.py
+++ b/superset/versioning/activity/impact.py
@@ -24,9 +24,11 @@ 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:`batch_chart_counts` — counts the matching charts without a
+  join: dashboard membership comes from ``charts_attached_to_dashboard``'s
+  attach/detach windows over ``dashboard_slices_version``, and a
+  member-scoped ``slices_version`` scan supplies the chart→dataset window;
+  the two are combined per pair by :func:`_count_attached_charts_at`.
 * :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).
@@ -38,6 +40,7 @@ inside another (no DB).
 
 from __future__ import annotations
 
+from collections.abc import Mapping, Sequence
 from typing import Any
 
 import sqlalchemy as sa
@@ -47,7 +50,14 @@ from superset.versioning.activity.kinds import (
     chunked_ids,
     ENTITY_ID_CHUNK_SIZE,
     TABLE_KIND_TO_API,
+    Window,
 )
+from superset.versioning.baseline import OPERATION_DELETE
+
+# Headroom left below SQLite's 999 bind-variable floor for the handful of 
scalar
+# binds in the slice-scan WHERE (datasource_type, operation_type, the two tx
+# bounds) once a member-id chunk and the dataset IN are accounted for.
+_SCALAR_BIND_HEADROOM = 20
 
 
 def collect_impact_pairs(
@@ -76,12 +86,17 @@ def batch_chart_counts(
     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.
+    No join: ``charts_attached_to_dashboard`` supplies each member chart's
+    ``[attach, detach)`` windows from the association shadow (Continuum never
+    closes an M2M shadow's ``end_transaction_id``, so a validity-window filter
+    on it would count a chart removed before ``target_tx`` — sc-119907), and a
+    member-scoped scan of ``slices_version`` supplies the chart→dataset window,
+    whose ``end_transaction_id`` the validity backfill *does* close, so the
+    ordinary validity predicate is right there. The Python loop counts a slice
+    for a pair when both an attachment window and its chart→dataset window
+    contain ``target_tx``. Replaces the previous N+1 shape that fired one COUNT
+    per related record, and the m2m⋈slices join whose M2M validity window was
+    the buggy naive filter.
 
     Returns ``{(dataset_id, target_tx): count}``; pairs whose count
     would be zero are omitted so the caller's ``.get(key, 0)`` is
@@ -94,73 +109,104 @@ def batch_chart_counts(
     from sqlalchemy_continuum import version_class
 
     from superset.models.slice import Slice
+    from superset.versioning.membership import charts_attached_to_dashboard
 
-    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),
+
+    # Attachment membership per slice. charts_attached_to_dashboard owns the
+    # association-shadow read and the attach/detach window pairing — the single
+    # place that must never filter the M2M shadow by end_transaction_id, which
+    # Continuum never closes (sc-119907). Reused here so restore.py, the
+    # activity relationship walk, and this rollup share one implementation.
+    attach_windows: dict[int, list[Window]] = {}
+    for slice_id, window in charts_attached_to_dashboard(dashboard_id):
+        attach_windows.setdefault(slice_id, []).append(window)
+    if not attach_windows:
+        return {}
+
+    # Chart→dataset validity from the slice parent shadow, whose
+    # end_transaction_id the validity backfill *does* close, so the ordinary
+    # half-open validity predicate is correct here. Bounded on the DB side to
+    # this dashboard's member charts (the attach_windows keys) and the
+    # transaction range; the member-id IN-clause is chunked to stay under
+    # SQLite's 999 bind-variable floor.
+    #
+    # The requested-dataset prune is applied on the DB side too, but only when
+    # the dataset set co-binds with a full member chunk under that floor — a
+    # member chunk (<= ENTITY_ID_CHUNK_SIZE) plus the dataset IN plus the few
+    # scalar binds must stay < 999. When there are too many requested datasets,
+    # the DB-side dataset predicate is dropped and the combiner filters 
datasets
+    # in Python (it already keys on pairs_by_dataset), so a wide dashboard does
+    # not overflow the bind limit (sc-119907 review).
+    filter_datasets_in_sql = (
+        len(dataset_ids) <= 999 - ENTITY_ID_CHUNK_SIZE - _SCALAR_BIND_HEADROOM
+    )
+    slice_rows: list[Any] = []
+    for chunk in chunked_ids(set(attach_windows), ENTITY_ID_CHUNK_SIZE):
+        conditions = [
+            slices_tbl.c.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.operation_type != OPERATION_DELETE,
             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())
+        ]
+        if filter_datasets_in_sql:
+            conditions.append(slices_tbl.c.datasource_id.in_(dataset_ids))
+        stmt = sa.select(
+            slices_tbl.c.id.label("slice_id"),
+            slices_tbl.c.datasource_id,
+            slices_tbl.c.transaction_id.label("slice_start"),
+            slices_tbl.c.end_transaction_id.label("slice_end"),
+        ).where(*conditions)
+        
slice_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:
+    return _count_attached_charts_at(attach_windows, slice_rows, 
pairs_by_dataset)
+
+
+def _count_attached_charts_at(
+    attach_windows: dict[int, list[Window]],
+    slice_rows: Sequence[Mapping[str, Any]],
+    pairs_by_dataset: dict[int, list[int]],
+) -> dict[tuple[int, int], int]:
+    """Pure combiner: for each ``(dataset_id, target_tx)``, count the distinct
+    charts whose attachment window and chart→dataset window both contain
+    ``target_tx``.
+
+    *attach_windows* maps ``slice_id`` to its ``[attach, detach)`` episodes
+    (from the association shadow — see
+    :func:`~superset.versioning.activity.windows.attachment_windows`); a chart
+    removed before ``target_tx`` has no window containing it and is therefore
+    not counted. *slice_rows* are the chart→dataset parent-shadow rows
+    (``slice_id``, ``datasource_id``, ``slice_start``, ``slice_end``), whose
+    ``end_transaction_id`` (``slice_end``) the validity backfill does close, so
+    the half-open validity predicate is correct for them. Split out of
+    :func:`batch_chart_counts` so this membership logic is unit-testable
+    without a live shadow-table fixture.
+    """
+    matches: dict[tuple[int, int], set[int]] = {}
+    for row in slice_rows:
+        windows = attach_windows.get(row["slice_id"])
+        if not windows:
+            continue
         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_attach = any(w.contains(target_tx) for w in windows)
             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:
+            if in_attach 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()}
 
 
diff --git a/superset/versioning/activity/queries.py 
b/superset/versioning/activity/queries.py
index 97fdf67a754..f87cb6033bf 100644
--- a/superset/versioning/activity/queries.py
+++ b/superset/versioning/activity/queries.py
@@ -16,8 +16,10 @@
 # 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 A relationship walks (``datasets_used_by_chart``,
+``batch_datasets_used_by_charts``; the dashboard-membership walk
+``charts_attached_to_dashboard`` lives in
+:mod:`superset.versioning.membership`),
 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
@@ -39,7 +41,6 @@ from __future__ import annotations
 import logging
 from datetime import datetime
 from heapq import heappush, heapreplace
-from itertools import groupby
 from typing import Any
 from uuid import UUID
 
@@ -119,94 +120,6 @@ def first_tracked_tx(
 # ---- Phase A: relationship-traversal queries ------------------------------
 
 
-# ``operation_type`` values on a Continuum association shadow row
-# (sqlalchemy_continuum.operation.Operation): INSERT attaches, DELETE detaches.
-# UPDATE never occurs for a pure M2M association (there is nothing to update on
-# a (dashboard, slice) pair); if it ever appeared it is ignored — neither
-# opening nor closing a window — so an open attachment simply continues.
-# These mirror the library enum's numeric values; 
``test_m2m_op_constants_match_
-# continuum`` pins them so a Continuum renumber fails loudly rather than 
silently.
-_M2M_OP_INSERT = 0
-_M2M_OP_DELETE = 2
-
-
-def _attachment_windows(
-    rows: list[tuple[int, int, int]],
-) -> list[tuple[int, Window]]:
-    """Pair INSERT / DELETE association-version rows into ``[attach, detach)``
-    windows, one per attachment episode.
-
-    Each row is ``(slice_id, transaction_id, operation_type)``. Continuum
-    **never closes** an association shadow row's ``end_transaction_id`` — its
-    unit-of-work only *inserts* association versions
-    (``create_association_versions``); the validity backfill that sets
-    ``end_transaction_id`` runs for parent objects, not for M2M links. So the
-    detach boundary lives on the DELETE row's ``transaction_id``, not on the
-    attach row's ``end_transaction_id`` (which stays NULL for the association's
-    whole life). An INSERT opens a window; the next DELETE closes it at its
-    transaction id; an attachment with no following DELETE stays open (the
-    chart is still on the dashboard). A DELETE at the same transaction as its
-    open (add-and-remove in one save) yields no window — the chart was never
-    on a committed dashboard state.
-    """
-    result: list[tuple[int, Window]] = []
-    # operation_type is part of the sort key so that, within one transaction,
-    # INSERT (0) sorts before DELETE (2): an add-and-remove in a single save is
-    # then seen open-before-close and collapses to no window (the DELETE finds
-    # ``tx == open_tx``, not ``>``). Do not drop it from the key.
-    rows_sorted = sorted(rows, key=lambda r: (r[0], r[1], r[2]))
-    for slice_id, group in groupby(rows_sorted, key=lambda r: r[0]):
-        open_tx: int | None = None
-        for _slice_id, tx, operation_type in group:
-            if operation_type == _M2M_OP_DELETE:
-                if open_tx is not None and tx > open_tx:
-                    result.append((slice_id, Window(open_tx, tx)))
-                open_tx = None
-            elif operation_type == _M2M_OP_INSERT and open_tx is None:
-                open_tx = tx
-        if open_tx is not None:
-            result.append((slice_id, Window(open_tx, None)))
-    return result
-
-
-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 attachment episode's validity window in
-    transaction-id space.
-
-    Reads from ``dashboard_slices_version`` (Continuum's auto-generated M2M
-    shadow) and pairs its INSERT/DELETE rows via :func:`_attachment_windows`,
-    so a chart removed from the dashboard is bounded at the detach transaction
-    rather than open-ended — otherwise the chart's edits made *after* removal
-    would surface in the dashboard's related history.
-    """
-    # 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.operation_type,
-            ).where(
-                m2m_tbl.c.dashboard_id == dashboard_id,
-                m2m_tbl.c.slice_id.is_not(None),
-            )
-        )
-        .all()
-    )
-    return _attachment_windows([(row[0], row[1], row[2]) for row in rows])
-
-
 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.
diff --git a/superset/versioning/activity/scope.py 
b/superset/versioning/activity/scope.py
index bc1d7912b5a..1975105a1a4 100644
--- a/superset/versioning/activity/scope.py
+++ b/superset/versioning/activity/scope.py
@@ -35,13 +35,13 @@ 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,
 )
+from superset.versioning.membership import charts_attached_to_dashboard
 
 
 def resolve_scope(
diff --git a/superset/versioning/activity/windows.py 
b/superset/versioning/activity/windows.py
index b024636e8ff..a5dba64dcfc 100644
--- a/superset/versioning/activity/windows.py
+++ b/superset/versioning/activity/windows.py
@@ -29,10 +29,76 @@ means "open-ended (current)" and behaves like positive 
infinity.
 
 from __future__ import annotations
 
+from itertools import groupby
 from typing import Any
 
 from superset.versioning.activity.kinds import EntityWindows, Window
 
+# ``operation_type`` values on a Continuum association shadow row
+# (sqlalchemy_continuum.operation.Operation): INSERT attaches, DELETE detaches.
+# UPDATE never occurs for a pure M2M association (there is nothing to update on
+# a (dashboard, slice) pair); if it ever appeared it is ignored — neither
+# opening nor closing a window — so an open attachment simply continues.
+# These mirror the library enum's numeric values; 
``test_m2m_op_constants_match_
+# continuum`` pins them so a Continuum renumber fails loudly rather than 
silently.
+M2M_OP_INSERT = 0
+M2M_OP_DELETE = 2
+
+
+def attachment_windows(
+    rows: list[tuple[int, int, int]],
+) -> list[tuple[int, Window]]:
+    """Pair INSERT / DELETE association-version rows into ``[attach, detach)``
+    windows, one per attachment episode.
+
+    Each row is ``(assoc_id, transaction_id, operation_type)``. Continuum
+    **never closes** an association shadow row's ``end_transaction_id`` — its
+    unit-of-work only *inserts* association versions
+    (``create_association_versions``); the validity backfill that sets
+    ``end_transaction_id`` runs for parent objects, not for M2M links. So the
+    detach boundary lives on the DELETE row's ``transaction_id``, not on the
+    attach row's ``end_transaction_id`` (which stays NULL for the association's
+    whole life). An INSERT opens a window; the next DELETE closes it at its
+    transaction id; an attachment with no following DELETE stays open (the
+    association is still live). A DELETE at the same transaction as its open
+    (add-and-remove in one save) yields no window — the association was never
+    on a committed state. That last case is a deliberate divergence from
+    Continuum's own ``association_subquery`` reverter, which (selecting the
+    ``MAX(tx) <= T`` row and excluding only DELETEs) would treat such a pair as
+    a member; the never-committed reading is the safer one for restore.
+
+    This is the M2M-correct counterpart to
+    :func:`~superset.versioning.changes.shadow_queries.shadow_rows_valid_at`,
+    whose ``end_transaction_id`` validity filter is right for parent/child
+    shadows but silently re-includes a detached association.
+    """
+    result: list[tuple[int, Window]] = []
+    # operation_type is part of the sort key so that, within one transaction,
+    # INSERT (0) sorts before DELETE (2): an add-and-remove in a single save is
+    # then seen open-before-close and collapses to no window (the DELETE finds
+    # ``tx == open_tx``, not ``>``). Do not drop it from the key.
+    #
+    # Corollary / assumption: because INSERT is forced before DELETE within a
+    # transaction, this cannot represent a *remove-then-re-add* of the same
+    # association in one transaction (it would read the same as add-then-remove
+    # → no window). That relies on no write path emitting DELETE-then-INSERT
+    # for the same association within a single transaction — which holds today
+    # (a chart is detached or attached in a save, not both), so the case is
+    # latent, not live. Revisit this pairing if such a write path is added.
+    rows_sorted = sorted(rows, key=lambda r: (r[0], r[1], r[2]))
+    for assoc_id, group in groupby(rows_sorted, key=lambda r: r[0]):
+        open_tx: int | None = None
+        for _assoc_id, tx, operation_type in group:
+            if operation_type == M2M_OP_DELETE:
+                if open_tx is not None and tx > open_tx:
+                    result.append((assoc_id, Window(open_tx, tx)))
+                open_tx = None
+            elif operation_type == M2M_OP_INSERT and open_tx is None:
+                open_tx = tx
+        if open_tx is not None:
+            result.append((assoc_id, Window(open_tx, None)))
+    return result
+
 
 def intersect_windows(outer: Window, inner: Window) -> Window | None:
     """Intersect two half-open ``[start_tx, end_tx)`` windows.
diff --git a/superset/versioning/membership.py 
b/superset/versioning/membership.py
new file mode 100644
index 00000000000..f7e37f3e570
--- /dev/null
+++ b/superset/versioning/membership.py
@@ -0,0 +1,86 @@
+# 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.
+"""M2M dashboard-membership queries, shared across the versioning surfaces.
+
+``charts_attached_to_dashboard`` reads the ``dashboard_slices_version``
+association shadow and pairs its INSERT/DELETE rows into ``[attach, detach)``
+windows. It must **never** filter that shadow by ``end_transaction_id``:
+Continuum never closes an M2M association's ``end_transaction_id`` (see
+:func:`~superset.versioning.activity.windows.attachment_windows`), so a
+validity filter would re-include a chart removed before the queried tx.
+
+This lives in a neutral module — not the activity read-path module
+(``activity/queries.py``) — so the restore write path and the impact rollup
+depend on it here rather than reaching up into the read path (sc-119907).
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import sqlalchemy as sa
+
+from superset.extensions import db
+
+if TYPE_CHECKING:
+    from superset.versioning.activity.kinds import Window
+
+
+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 attachment episode's validity window in
+    transaction-id space.
+
+    Reads from ``dashboard_slices_version`` (Continuum's auto-generated M2M
+    shadow) and pairs its INSERT/DELETE rows via
+    :func:`~superset.versioning.activity.windows.attachment_windows`,
+    so a chart removed from the dashboard is bounded at the detach transaction
+    rather than open-ended — otherwise the chart's edits made *after* removal
+    would surface in the dashboard's related history.
+    """
+    # pylint: disable=import-outside-toplevel
+    # attachment_windows is imported lazily (not at module top) so that this
+    # module stays a leaf: importing it must not pull the activity package,
+    # whose read-path modules (scope.py) import charts_attached_to_dashboard
+    # back from here — a module-top import created a circular import that only
+    # surfaced at runtime, when a restore imported this module for the first
+    # time (sc-119907).
+    from sqlalchemy_continuum import version_class
+
+    from superset.models.dashboard import Dashboard
+    from superset.versioning.activity.windows import attachment_windows
+
+    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.operation_type,
+            ).where(
+                m2m_tbl.c.dashboard_id == dashboard_id,
+                m2m_tbl.c.slice_id.is_not(None),
+            )
+        )
+        .all()
+    )
+    return attachment_windows([(row[0], row[1], row[2]) for row in rows])
diff --git a/superset/versioning/restore.py b/superset/versioning/restore.py
index bf5a021e72d..b56bafe08c6 100644
--- a/superset/versioning/restore.py
+++ b/superset/versioning/restore.py
@@ -195,42 +195,40 @@ def _restore_dashboard_membership(dashboard: Any, 
transaction_id: int) -> list[i
     """Reset *dashboard*'s chart membership to what it was at
     *transaction_id*, reattaching only charts that still exist.
 
-    Reads the validity-windowed ``dashboard_slices_version`` shadow
-    (Continuum's auto-generated M2M table): a slice was a member at tx T
-    iff a non-DELETE row has ``transaction_id <= T`` and an open or
-    later-closing validity window.
+    Membership is derived from the ``dashboard_slices_version`` shadow
+    (Continuum's auto-generated M2M table) by pairing each slice's
+    INSERT/DELETE rows into ``[attach, detach)`` windows: a slice was a
+    member at tx T iff one of its attachment windows contains T. The
+    ``shadow_rows_valid_at`` validity filter must **not** be used here —
+    Continuum never closes an association shadow's ``end_transaction_id``,
+    so that filter would re-attach a chart that had been removed before T
+    (attached@1, removed@5, restore to tx10 → the chart wrongly returns).
+    ``shadow_rows_valid_at`` stays correct for parent/child shadows, whose
+    ``end_transaction_id`` the validity backfill does close (sc-119907).
 
     Returns the ids of snapshot members that no longer exist and were
     skipped. Live charts' content is never touched — restoring a chart's
     content is the chart's own restore endpoint's job.
     """
     # pylint: disable=import-outside-toplevel
-    # Local imports: models.slice transitively imports models.core, which
-    # needs the initialised app — module-top import would recreate the
-    # bootstrap cycle documented in changes/listener.py; shadow_queries is
-    # imported lazily for the same reason (see queries.get_version).
+    # Local imports: models.slice transitively imports models.core, which needs
+    # the initialised app — a module-top import would recreate the bootstrap
+    # cycle documented in changes/listener.py. charts_attached_to_dashboard is
+    # imported lazily for the same reason: it pulls the window helpers, whose
+    # package transitively imports the versioning.changes listener graph, so a
+    # module-top import here would re-enter that same bootstrap cycle.
     from superset.models.slice import Slice
-    from superset.versioning.changes import shadow_rows_valid_at
+    from superset.versioning.membership import charts_attached_to_dashboard
 
-    ver_cls = version_class(type(dashboard))
-    m2m_tbl = ver_cls.__table__.metadata.tables.get("dashboard_slices_version")
-    if m2m_tbl is None:  # pragma: no cover — shadow tables always exist here
-        return []
-
-    # shadow_rows_valid_at owns the validity-window semantics (open or
-    # later-closing window, non-DELETE) — the same predicate the version
-    # snapshot's column/metric reconstruction uses.
+    # charts_attached_to_dashboard owns the association-shadow read and the
+    # attach/detach window pairing (the single place that must never filter the
+    # M2M shadow by end_transaction_id — Continuum never closes it). A slice 
was
+    # a member at transaction_id iff one of its windows contains it 
(sc-119907).
     member_ids = sorted(
         {
-            row["slice_id"]
-            for row in shadow_rows_valid_at(
-                db.session,
-                m2m_tbl,
-                "dashboard_id",
-                dashboard.id,
-                transaction_id,
-            )
-            if row["slice_id"] is not None
+            slice_id
+            for slice_id, window in charts_attached_to_dashboard(dashboard.id)
+            if window.contains(transaction_id)
         }
     )
     if not member_ids:
diff --git a/tests/integration_tests/dashboards/version_restore_tests.py 
b/tests/integration_tests/dashboards/version_restore_tests.py
index cee290549ff..f372aa17eec 100644
--- a/tests/integration_tests/dashboards/version_restore_tests.py
+++ b/tests/integration_tests/dashboards/version_restore_tests.py
@@ -201,6 +201,76 @@ class TestDashboardRestoreApi(SupersetTestCase):
             f"got {restored_ids}"
         )
 
+    def test_restore_to_tx_after_removal_does_not_reattach_chart(self) -> None:
+        """The inverse of the re-attach case (sc-119907): restoring to a
+        snapshot captured *after* a chart was removed must NOT bring it back.
+
+        Continuum never closes an association shadow row's
+        ``end_transaction_id``, so the removed chart's INSERT row still looks
+        "valid at" any later tx under a naive validity filter — it would be
+        wrongly re-attached. Membership must be derived from paired
+        attach/detach windows: the window ``[attach, remove)`` does not contain
+        the post-removal target tx, so the chart stays off.
+        """
+        from superset.daos.version import derive_version_uuid
+
+        _persist_fixture_state()
+        dashboard: Dashboard = (
+            db.session.query(Dashboard)
+            .filter(Dashboard.dashboard_title == "USA Births Names")
+            .first()
+        )
+        assert dashboard is not None
+        dashboard_uuid = str(dashboard.uuid)
+        dashboard_id = dashboard.id
+        entity_uuid = dashboard.uuid
+        assert entity_uuid is not None
+
+        original_slice_ids = sorted(s.id for s in dashboard.slices)
+        assert len(original_slice_ids) >= 2, (
+            f"fixture expected to attach >= 2 charts; got {original_slice_ids}"
+        )
+        slice_to_drop = dashboard.slices[0]
+        drop_id = slice_to_drop.id
+
+        # Remove the chart and commit — the detach is recorded in history.
+        dashboard.slices.remove(slice_to_drop)
+        db.session.commit()
+
+        # Touch the dashboard AFTER the removal so the restore target snapshot
+        # post-dates the detach.
+        dashboard.dashboard_title = "USA Births Names — post-removal snapshot"
+        db.session.commit()
+
+        ver_cls = version_class(Dashboard)
+        target_tx = (
+            db.session.query(ver_cls.transaction_id)
+            .filter(ver_cls.id == dashboard_id, ver_cls.uuid == entity_uuid)
+            .order_by(ver_cls.transaction_id.desc())
+            .limit(1)
+            .scalar()
+        )
+        assert target_tx is not None
+        target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
+
+        self.login(ADMIN_USERNAME)
+        rv = self._restore(dashboard_uuid, target_uuid)
+        assert rv.status_code == 200, rv.data
+
+        db.session.expire_all()
+        dashboard = (
+            db.session.query(Dashboard).filter(Dashboard.id == 
dashboard_id).one()
+        )
+        restored_ids = sorted(s.id for s in dashboard.slices)
+        assert drop_id not in restored_ids, (
+            "restore re-attached a chart that was removed before the target "
+            f"snapshot: {drop_id} in {restored_ids}"
+        )
+        survivors = sorted(set(original_slice_ids) - {drop_id})
+        assert restored_ids == survivors, (
+            f"expected the surviving members {survivors}, got {restored_ids}"
+        )
+
     def test_restore_preserves_live_chart_content(self) -> None:
         """Dashboard restore is membership-only: a member chart edited
         AFTER the snapshot keeps its current content — charts are shared
diff --git a/tests/unit_tests/versioning/test_activity.py 
b/tests/unit_tests/versioning/test_activity.py
index 98e91d1be41..21f9b6f55bd 100644
--- a/tests/unit_tests/versioning/test_activity.py
+++ b/tests/unit_tests/versioning/test_activity.py
@@ -44,6 +44,8 @@ from superset.versioning.activity import (
     Window,
 )
 from superset.versioning.activity.impact import (
+    _count_attached_charts_at,
+    batch_chart_counts,
     collect_impact_pairs,
     impact_for_record,
 )
@@ -54,9 +56,6 @@ from superset.versioning.activity.orchestrator import (
     _MAX_PAGE_SIZE,
 )
 from superset.versioning.activity.queries import (
-    _attachment_windows,
-    _M2M_OP_DELETE,
-    _M2M_OP_INSERT,
     _merge_result_into_heap,
     _record_sort_key,
     BoundedRecordHeap,
@@ -68,7 +67,10 @@ from superset.versioning.activity.render import (
 )
 from superset.versioning.activity.scope import resolve_scope
 from superset.versioning.activity.windows import (
+    attachment_windows,
     intersect_windows,
+    M2M_OP_DELETE,
+    M2M_OP_INSERT,
     merge_entity_windows,
     row_within_any_window,
     union_windows,
@@ -893,7 +895,7 @@ def test_build_summary_meta_headline_branches() -> None:
     assert _build_summary("Dashboard", unknown) == "Dashboard updated"
 
 
-# ---- _attachment_windows -------------------------------------------------
+# ---- attachment_windows -------------------------------------------------
 
 
 def test_attachment_windows_closes_at_detach() -> None:
@@ -903,7 +905,7 @@ def test_attachment_windows_closes_at_detach() -> None:
     row's transaction id — an INSERT at t1 paired with a DELETE at t3 yields
     the half-open window [t1, t3), so an edit at t2 (while attached) is
     inside it and an edit at t4 (after removal) is not."""
-    windows = _attachment_windows([(7, 1, 0), (7, 3, 2)])  # INSERT@1, DELETE@3
+    windows = attachment_windows([(7, 1, 0), (7, 3, 2)])  # INSERT@1, DELETE@3
 
     assert windows == [(7, Window(1, 3))]
     window = windows[0][1]
@@ -916,7 +918,7 @@ def test_attachment_windows_still_attached_is_open_ended() 
-> None:
     """A chart with an INSERT and no following DELETE is still on the
     dashboard, so its window is open-ended (end_tx = None) and every later
     edit remains in scope."""
-    assert _attachment_windows([(7, 5, 0)]) == [(7, Window(5, None))]
+    assert attachment_windows([(7, 5, 0)]) == [(7, Window(5, None))]
 
 
 def test_attachment_windows_reattach_cycles_and_ordering() -> None:
@@ -924,20 +926,30 @@ def 
test_attachment_windows_reattach_cycles_and_ordering() -> None:
     is independent of the row order the query returns them in (the rows are
     sorted by (slice_id, transaction_id) internally)."""
     rows = [(7, 7, 2), (7, 1, 0), (7, 5, 0), (7, 3, 2)]  # deliberately 
shuffled
-    assert _attachment_windows(rows) == [(7, Window(1, 3)), (7, Window(5, 7))]
+    assert attachment_windows(rows) == [(7, Window(1, 3)), (7, Window(5, 7))]
+
+
+def test_attachment_windows_reattach_leaves_the_last_episode_open() -> None:
+    """Attach@1, detach@5, re-attach@8 with no later detach: the first episode
+    is the closed window [1, 5) and the current attachment is open-ended
+    [8, None). An edit at tx 10 falls inside the live episode."""
+    windows = attachment_windows([(7, 1, 0), (7, 5, 2), (7, 8, 0)])
+    assert windows == [(7, Window(1, 5)), (7, Window(8, None))]
+    assert windows[1][1].contains(10)
+    assert not windows[0][1].contains(10)
 
 
 def test_attachment_windows_add_and_remove_same_transaction() -> None:
     """Attaching and detaching in a single save (INSERT and DELETE at the
     same transaction) leaves the chart on no committed dashboard state, so it
     contributes no window (and no degenerate zero-width interval)."""
-    assert _attachment_windows([(7, 2, 0), (7, 2, 2)]) == []
+    assert attachment_windows([(7, 2, 0), (7, 2, 2)]) == []
 
 
 def test_attachment_windows_separates_slices() -> None:
     """Each slice gets its own independent windows."""
     rows = [(7, 1, 0), (7, 3, 2), (9, 2, 0)]
-    assert _attachment_windows(rows) == [
+    assert attachment_windows(rows) == [
         (7, Window(1, 3)),
         (9, Window(2, None)),
     ]
@@ -945,10 +957,123 @@ def test_attachment_windows_separates_slices() -> None:
 
 def test_m2m_op_constants_match_continuum() -> None:
     """The association-shadow operation-type constants used by
-    _attachment_windows are the numeric values of Continuum's Operation enum.
+    attachment_windows are the numeric values of Continuum's Operation enum.
     Pin them so a library renumber fails here loudly rather than silently
     mis-pairing attach/detach rows."""
     from sqlalchemy_continuum import Operation
 
-    assert _M2M_OP_INSERT == Operation.INSERT
-    assert _M2M_OP_DELETE == Operation.DELETE
+    assert M2M_OP_INSERT == Operation.INSERT
+    assert M2M_OP_DELETE == Operation.DELETE
+
+
+# ---- _count_attached_charts_at (batch_chart_counts membership) -----------
+
+
+def _slice_row(
+    slice_id: int, datasource_id: int, start: int, end: int | None
+) -> dict[str, Any]:
+    """A chart→dataset parent-shadow row as batch_chart_counts fetches it."""
+    return {
+        "slice_id": slice_id,
+        "datasource_id": datasource_id,
+        "slice_start": start,
+        "slice_end": end,
+    }
+
+
+def test_count_attached_charts_excludes_chart_removed_before_target() -> None:
+    """sc-119907: a chart attached@1 and removed@5 must NOT be counted for a
+    dataset rollup at target_tx=10. Its attachment window [1, 5) does not
+    contain 10, even though its (never-closed) association shadow row would
+    pass a naive end_transaction_id validity filter."""
+    attach_windows = {7: [Window(1, 5)]}
+    slice_rows = [_slice_row(7, 100, 1, None)]  # chart→dataset open the whole 
time
+    result = _count_attached_charts_at(attach_windows, slice_rows, {100: [10]})
+    assert result == {}  # zero-count pairs are omitted
+
+
+def test_count_attached_charts_counts_chart_inside_its_window() -> None:
+    """The same chart IS counted at a target inside its attachment window."""
+    attach_windows = {7: [Window(1, 5)]}
+    slice_rows = [_slice_row(7, 100, 1, None)]
+    result = _count_attached_charts_at(attach_windows, slice_rows, {100: [3]})
+    assert result == {(100, 3): 1}
+
+
+def test_count_attached_charts_requires_both_windows() -> None:
+    """A chart attached at target_tx but not yet pointing at the dataset (its
+    chart→dataset window starts later) is not counted."""
+    attach_windows = {7: [Window(1, None)]}
+    slice_rows = [_slice_row(7, 100, 8, None)]  # points at dataset only from 
tx8
+    assert _count_attached_charts_at(attach_windows, slice_rows, {100: [3]}) 
== {}
+
+
+def test_count_attached_charts_dedupes_within_pair() -> None:
+    """Multiple parent-shadow rows for the same slice count the slice once."""
+    attach_windows = {7: [Window(1, None)]}
+    slice_rows = [_slice_row(7, 100, 1, 4), _slice_row(7, 100, 4, None)]
+    assert _count_attached_charts_at(attach_windows, slice_rows, {100: [5]}) 
== {
+        (100, 5): 1
+    }
+
+
+def test_count_attached_charts_ignores_slice_never_on_dashboard() -> None:
+    """A slice pointing at the dataset but with no attachment window (never on
+    this dashboard) does not contribute — guards the no-join fetch that may
+    return slices from other dashboards sharing the dataset."""
+    attach_windows: dict[int, list[Window]] = {}
+    slice_rows = [_slice_row(7, 100, 1, None)]
+    assert _count_attached_charts_at(attach_windows, slice_rows, {100: [3]}) 
== {}
+
+
+# ---- batch_chart_counts bind-variable floor (sc-119907) ------------------
+
+
+def test_batch_chart_counts_stays_under_sqlite_bind_floor(app_context: None) 
-> None:
+    """sc-119907: a wide dashboard (many member charts AND many requested
+    datasets) must not build a slice-scan statement that exceeds SQLite's 999
+    bind-variable floor. The member-id IN is chunked; the requested-dataset IN
+    is dropped to Python-side filtering when it would not co-bind under the
+    floor. Compiles every issued statement and asserts its bind count stays
+    safely under 999 — the pre-fix code (dataset IN always on) bound
+    500 member + 600 dataset + scalars = ~1104 and would 500 on SQLite."""
+
+    from sqlalchemy.dialects import sqlite
+
+    member_windows = [(sid, Window(1, None)) for sid in range(1, 601)]
+    pairs = {(ds, 5) for ds in range(10_000, 10_600)}  # 600 requested datasets
+    captured: list[Any] = []
+
+    class _FakeResult:
+        def mappings(self) -> "_FakeResult":
+            return self
+
+        def all(self) -> list[Any]:
+            return []
+
+    def _fake_execute(stmt: Any) -> "_FakeResult":
+        captured.append(stmt)
+        return _FakeResult()
+
+    with (
+        patch(
+            "superset.versioning.membership.charts_attached_to_dashboard",
+            return_value=member_windows,
+        ),
+        patch("superset.versioning.activity.impact.db") as mock_db,
+    ):
+        mock_db.session.connection.return_value.execute.side_effect = 
_fake_execute
+        batch_chart_counts(1, pairs)
+
+    assert captured, "expected at least one slice-scan statement"
+    for stmt in captured:
+        # render_postcompile expands ``IN (...)`` (an expanding bind in
+        # SQLAlchemy 2.0) into one param per element, so the count reflects
+        # what actually hits SQLite — a plain compile would show one bind per
+        # IN and hide the overflow.
+        compiled = stmt.compile(
+            dialect=sqlite.dialect(),
+            compile_kwargs={"render_postcompile": True},
+        )
+        n_binds = len(compiled.params)
+        assert n_binds < 999, f"statement binds {n_binds} params (SQLite floor 
999)"

Reply via email to