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

aminghadersohi 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 afa98f3391a perf(deletion-retention): one window pass for repeat 
predicate (#44349)
afa98f3391a is described below

commit afa98f3391aac8022c926b7e07ccebc7d588451c
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Sat Oct 3 18:58:35 2026 +1000

    perf(deletion-retention): one window pass for repeat predicate (#44349)
---
 .../commands/deletion_retention/prune_audit.py     | 76 ++++++++++++++++---
 .../deletion_retention/prune_audit_tests.py        | 88 ++++++++++++++++++++++
 .../deletion_retention/test_prune_audit.py         | 33 +++++++-
 3 files changed, 186 insertions(+), 11 deletions(-)

diff --git a/superset/commands/deletion_retention/prune_audit.py 
b/superset/commands/deletion_retention/prune_audit.py
index 7d14048a693..927f9eb6b90 100644
--- a/superset/commands/deletion_retention/prune_audit.py
+++ b/superset/commands/deletion_retention/prune_audit.py
@@ -347,6 +347,20 @@ def _in_current_streak(target: sa.FromClause, now: 
datetime) -> sa.ColumnElement
     return sa.or_(boundary.is_(None), target.c.created_on > boundary)
 
 
+#: Column names of the timestamp-groups derived table used by the window
+#: repeat check. The groups' labels, the named window's ``ORDER BY`` and the
+#: ``LAG`` columns are all built from these, so renaming one cannot drift the
+#: others: the window text is raw SQL and would otherwise keep the old name.
+_GROUP_TS = "ts"
+_GROUP_LAG_COLUMNS: tuple[str, ...] = (
+    _GROUP_TS,
+    "n",
+    "n_coded",
+    "min_reason",
+    "max_reason",
+)
+
+
 def _repeat_path() -> Literal["legacy", "window"]:
     """Select a query using initialized, vendor-normalized server 
capabilities."""
     dialect: sa.engine.Dialect = db.session.get_bind().dialect
@@ -468,6 +482,31 @@ def _window_repeats_an_earlier_block(
     PostgreSQL round-1 plan materialized a groups CTE and joined on entity
     alone before filtering ranks, comparing 36 million row pairs.
 
+    All five ``LAG`` columns share one SQL-level named ``WINDOW w`` (a raw
+    text fragment: SQLAlchemy Core has no construct for a named ``WINDOW``
+    clause) instead of five separate ``LAG(...) OVER (...)`` expressions that
+    happen to repeat the same partition/order spec. PostgreSQL already
+    recognizes five identical inline window specs as one logical pass, but
+    MySQL 8 does not merge them — each materializes its own temporary table,
+    roughly five sequential passes over the batch's timestamp groups. A named
+    window is the SQL-level way to say "this is the same window" so MySQL
+    evaluates it once; PostgreSQL and SQLite (>= 3.25) accept the same syntax
+    unchanged. Only servers routed here run this clause: the MySQL<8,
+    MariaDB<10.2 and SQLite<3.25 fallback in
+    :func:`_legacy_repeats_an_earlier_block` never reaches it.
+
+    An equality join back to a *second* instance of the timestamp-groups
+    derived table (fetching P's aggregates via
+    ``(entity_type, entity_uuid, ts = prev_ts)`` instead of four more ``LAG``
+    columns) was measured and rejected here: PostgreSQL's planner
+    misestimates the derived table's row count for a single-entity batch scope
+    and chooses a Nested Loop over an unindexed ``Materialize`` of the second
+    instance — an O(batch × history) comparison, the same failure shape as
+    the sc-120493 round-1 CTE/entity-only-merge regression, just via a
+    different join path. The named-``WINDOW`` shape keeps the exact join
+    structure already measured safe on PostgreSQL (sc-120493): only the
+    ``LAG`` columns' SQL text changes.
+
     Keep the repeat-id query uncorrelated: the sc-120493 Variant 2
     measurements showed MySQL repeatedly executing
     per-row predecessor scalars. During re-check, scope blocked rows, timestamp
@@ -500,11 +539,12 @@ def _window_repeats_an_earlier_block(
         .correlate(None)
         .subquery("blocked_rows")
     )
+    groups_name: str = "blocked_timestamp_groups"
     groups: sa.Subquery = (
         sa.select(
             source.c.entity_type,
             source.c.entity_uuid,
-            source.c.created_on.label("ts"),
+            source.c.created_on.label(_GROUP_TS),
             sa.func.count().label("n"),
             sa.func.count(source.c.reason).label("n_coded"),
             sa.func.min(source.c.reason).label("min_reason"),
@@ -514,21 +554,39 @@ def _window_repeats_an_earlier_block(
         .where(*blocked_filters, *scope)
         .group_by(source.c.entity_type, source.c.entity_uuid, 
source.c.created_on)
         .correlate(None)
-        .subquery("blocked_timestamp_groups")
+        .subquery(groups_name)
+    )
+    # A raw-text named WINDOW clause: the one construct SQLAlchemy Core
+    # cannot emit. ``groups_name`` is the literal alias every LAG column
+    # below must qualify with, so the FROM clause and the WINDOW clause
+    # stay on the same alias. The ordering and LAG column names come from
+    # the module constants shared with the groups' labels.
+    window_body: str = (
+        f"{groups_name}.entity_type, {groups_name}.entity_uuid "
+        f"ORDER BY {groups_name}.{_GROUP_TS}"
     )
     grp: sa.Subquery = (
         sa.select(
             groups,
             *[
-                sa.func.lag(groups.c[name])
-                .over(
-                    partition_by=(groups.c.entity_type, groups.c.entity_uuid),
-                    order_by=groups.c.ts,
-                )
-                .label(f"prev_{name}")
-                for name in ("ts", "n", "n_coded", "min_reason", "max_reason")
+                # Raw text carries no type, so copy the grouped column's own
+                # type across. Without it every prev_* lands as NullType and a
+                # later comparison against a Python value would bind it with no
+                # type processor. Types never reach the emitted SQL, which 
stays
+                # byte-identical on PostgreSQL, MySQL and SQLite.
+                sa.literal_column(
+                    f"lag({groups_name}.{name}) OVER w", 
type_=groups.c[name].type
+                ).label(f"prev_{name}")
+                for name in _GROUP_LAG_COLUMNS
             ],
         )
+        .select_from(groups)
+        # Suffixes trail every other clause, so never add ORDER BY/LIMIT to
+        # this select: they would be emitted before WINDOW and fail to parse.
+        # Aliasing this select (``aliased()``/``.alias()``, or any ORM
+        # adaption over ``grp``) rewrites the FROM but not the raw column
+        # text or this suffix, so both would keep naming the old alias.
+        .suffix_with(f"WINDOW w AS (PARTITION BY {window_body})")
         .correlate(None)
         .subquery("preceding_groups")
     )
diff --git a/tests/integration_tests/deletion_retention/prune_audit_tests.py 
b/tests/integration_tests/deletion_retention/prune_audit_tests.py
index 5291ee03fc7..05a32400936 100644
--- a/tests/integration_tests/deletion_retention/prune_audit_tests.py
+++ b/tests/integration_tests/deletion_retention/prune_audit_tests.py
@@ -52,6 +52,7 @@ from superset.models.purge_audit_log import (
     STATUS_PENDING,
     STATUS_TARGET_ABSENT,
 )
+from superset.utils import json
 from superset.utils.core import backend
 from tests.integration_tests.base_tests import SupersetTestCase
 from tests.integration_tests.deletion_retention._base import (
@@ -1111,6 +1112,11 @@ class TestRepeatPredicateEquivalence(SupersetTestCase):
                         now, scope, ids
                     )
 
+                # "evidence" is the control: it never reaches the patched
+                # predicate, so equality there asserts isolation from the
+                # rewrite rather than equivalence of it. A change that made it
+                # differ would mean the rewrite had leaked out of the two
+                # categories that call ``_repeats_an_earlier_block``.
                 for category in ("duplicate", "operational", "evidence"):
                     assert fallback[category] == legacy[category], (
                         f"seed={seed} category={category}: legacy fallback 
diverged"
@@ -1122,3 +1128,85 @@ class TestRepeatPredicateEquivalence(SupersetTestCase):
                     )
         # Guard against a generator regression that quietly checks nothing.
         assert checked_rows > 1000
+
+
+def _windowing_nodes(plan: Any) -> list[dict[str, Any]]:
+    """Every ``windowing`` node in a MySQL ``EXPLAIN FORMAT=JSON`` document."""
+    nodes: list[dict[str, Any]] = []
+    if isinstance(plan, dict):
+        for key, value in plan.items():
+            if key == "windowing":
+                nodes.append(value)
+            nodes.extend(_windowing_nodes(value))
+    elif isinstance(plan, list):
+        for item in plan:
+            nodes.extend(_windowing_nodes(item))
+    return nodes
+
+
+class TestRepeatQueryPlan(SupersetTestCase):
+    """The repeat query's window functions execute as one MySQL pass."""
+
+    def setUp(self) -> None:
+        super().setUp()
+        self._cleanup()
+
+    def tearDown(self) -> None:
+        self._cleanup()
+        super().tearDown()
+
+    def _cleanup(self) -> None:
+        db.session.rollback()
+        db.session.execute(
+            sa.delete(PurgeAuditLog.__table__).where(
+                PurgeAuditLog.__table__.c.entity_type == _EQUIV_ENTITY_TYPE
+            )
+        )
+        db.session.commit()
+
+    def test_mysql_repeat_query_evaluates_all_lag_columns_in_one_window_pass(
+        self,
+    ) -> None:
+        """Assert the plan, not just the SQL text.
+
+        MySQL 8 sorts and buffers once per distinct window specification, so
+        five identical inline ``OVER (PARTITION BY ... ORDER BY ...)`` clauses
+        cost five passes over the timestamp groups. The named window must show
+        up in ``EXPLAIN FORMAT=JSON`` as a single windowing step carrying every
+        ``LAG``; the compiled-SQL shape checks alone would not notice the
+        server planning it differently.
+        """
+        dialect: sa.engine.Dialect = db.session.get_bind().dialect
+        if dialect.name != "mysql" or getattr(dialect, "is_mariadb", False):
+            pytest.skip("MySQL-specific execution plan")
+        if prune_audit._repeat_path() != "window":
+            pytest.skip("this server runs the legacy correlated repeat check")
+
+        now: datetime = audit.utc_now()
+        rows: list[dict[str, Any]] = _random_history(random.Random(0), now)  # 
noqa: S311 — deterministic test data, not crypto
+        db.session.execute(sa.insert(PurgeAuditLog.__table__), rows)
+        db.session.commit()
+
+        table: sa.Table = PurgeAuditLog.__table__
+        query: sa.sql.Select = sa.select(table.c.id).where(
+            table.c.entity_type == _EQUIV_ENTITY_TYPE,
+            *prune_audit._duplicate_predicates(
+                table, now, [(_EQUIV_ENTITY_TYPE, rows[0]["entity_uuid"])]
+            ),
+        )
+        compiled: str = str(
+            query.compile(dialect=dialect, compile_kwargs={"literal_binds": 
True})
+        )
+        # The statement is compiled from this module's own constants and
+        # literal-rendered bind values; EXPLAIN takes no bind parameters.
+        plan: dict[str, Any] = json.loads(
+            db.session.execute(sa.text("EXPLAIN FORMAT=JSON " + 
compiled)).scalar_one()
+        )
+
+        windowing: list[dict[str, Any]] = _windowing_nodes(plan)
+        assert len(windowing) == 1, "the repeat query must plan one windowing 
step"
+        windows: list[dict[str, Any]] = windowing[0]["windows"]
+        assert len(windows) == 1, (
+            "the LAG columns must share one window, not one sort each"
+        )
+        assert windows[0]["functions"] == ["lag"] * 
len(prune_audit._GROUP_LAG_COLUMNS)
diff --git a/tests/unit_tests/commands/deletion_retention/test_prune_audit.py 
b/tests/unit_tests/commands/deletion_retention/test_prune_audit.py
index 38cb8064179..1b6b53462b3 100644
--- a/tests/unit_tests/commands/deletion_retention/test_prune_audit.py
+++ b/tests/unit_tests/commands/deletion_retention/test_prune_audit.py
@@ -739,7 +739,31 @@ def 
test_evidence_drain_skips_scope_lookup_but_rechecks_under_lock() -> None:
     ] == [row_id]
 
 
-def test_repeat_query_uses_lag_without_ctes() -> None:
+def test_only_the_evidence_category_opts_out_of_the_locked_scope_lookup() -> 
None:
+    """Wire the opt-out to the one category whose predicates take no scope."""
+    mock_delete: MagicMock
+    with patch.dict(current_app.config, {EVIDENCE_RETENTION_KEY: 3650}):
+        with patch.object(
+            prune_audit, "_delete_batch", return_value=(0, 0)
+        ) as mock_delete:
+            prune_audit.run_prune()
+    # Both entity-partitioned categories must keep the scope lookup: without it
+    # the coordination-locked re-check drops its entity narrowing and rescans
+    # whole history while holding the lock.
+    assert [
+        (type(call.args[1]), call.kwargs["needs_entity_scope"])
+        for call in mock_delete.call_args_list
+    ] == [
+        (prune_audit._DuplicateRecheck, True),
+        (prune_audit._OperationalRecheck, True),
+        (prune_audit._EvidenceRecheck, False),
+    ]
+
+
[email protected](
+    "dialect", [postgresql.dialect(), mysql.dialect(), sqlite.dialect()]
+)
+def test_repeat_query_uses_lag_without_ctes(dialect: Any) -> None:
     """Keep the measured derived-table shape without materialized group 
self-joins."""
     table: sa.Table = prune_audit.PurgeAuditLog.__table__
     query: sa.sql.Select = sa.select(table.c.id).where(
@@ -747,10 +771,15 @@ def test_repeat_query_uses_lag_without_ctes() -> None:
             table, datetime(2026, 1, 1), scope_entities=[("chart", "entity")]
         )
     )
-    sql: str = str(query.compile(dialect=postgresql.dialect())).lower()
+    sql: str = str(query.compile(dialect=dialect)).lower()
     assert "with " not in sql
     assert "lag(" in sql
     assert "dense_rank(" not in sql
+    # The point of the shape: every LAG shares one named window, so the
+    # engine makes a single ordered pass instead of one per column.
+    assert sql.count("window w as (") == 1
+    assert sql.count("over w") == 5
+    assert "over (partition" not in sql
 
 
 @pytest.mark.parametrize("count", [1, 2, prune_audit.MAX_BATCH_SIZE])

Reply via email to