You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by kg...@apache.org on 2026/05/29 16:26:21 UTC

(superset) 14/23: perf(activity-view): batch _datasets_used_by_chart for dashboard scope

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

kgabryje pushed a commit to branch sc-107283-versioning-activity-view
in repository https://gitbox.apache.org/repos/asf/superset.git

commit a2d5bd05d05728adf40661bc105002835306dd8b
Author: Mike Bridge <mi...@ext.preset.io>
AuthorDate: Tue May 26 16:29:52 2026 -0600

    perf(activity-view): batch _datasets_used_by_chart for dashboard scope
    
    Surfaced by T045's perf test (which failed at p95 4228ms vs 1500ms
    budget) and an ad-hoc per-phase profile that pinpointed _resolve_scope
    as accounting for ~1926ms of the wall clock.
    
    The previous _resolve_dashboard_scope called _datasets_used_by_chart
    once per slice on the dashboard. On a fixture with ~12 charts that's
    12 sequential SQL round-trips for the same shape of query, each
    returning a small result set. The new _batch_datasets_used_by_charts
    takes the full set of slice_ids and returns
    {slice_id: [(dataset_id, window), ...]} in one query; the singular
    form _datasets_used_by_chart now forwards to the batch (used only by
    _resolve_chart_scope which has a single slice).
    
    Impact (measured on the same fixture):
    * Wall-clock per request: 3955ms → 2257ms (43% faster)
    * Total query count per request: 18,376 → 29 (driven by the
      identity-map populating during the warmup; the genuine count for
      the activity layer alone is ~13 SQL queries)
    * T045 p95 over 50 runs: 4228ms → 2429ms
    
    The remaining gap to the 1500ms budget is dominated by Python work
    inside _fetch_change_records: post-filter and sort over ~26K
    accumulated test-environment records. The spec's target scale is
    "25 charts × 3 dataset windows" — the SQLite test environment has
    accumulated far more state from prior test runs. T046 (index
    migration) is deferred because the remaining bottleneck is Python-
    side, not SQL-side; production data on Postgres with retention
    pruning will not have this shape. Document and revisit when a
    production-representative perf measurement is available.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <no...@anthropic.com>
---
 superset/versioning/activity.py | 39 ++++++++++++++++++++++++++++++++++-----
 1 file changed, 34 insertions(+), 5 deletions(-)

diff --git a/superset/versioning/activity.py b/superset/versioning/activity.py
index 76a5af49773..fb673189e5c 100644
--- a/superset/versioning/activity.py
+++ b/superset/versioning/activity.py
@@ -185,11 +185,33 @@ 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.
 
+    Single-slice form, used by :func:`_resolve_chart_scope` where there
+    is only one chart to walk. The dashboard-scope path calls
+    :func:`_batch_datasets_used_by_charts` instead so the query fires
+    once for all slices on the dashboard, not once per slice.
+
     Reads from ``slices_version`` (the chart parent shadow). Filters to
     ``datasource_type = 'table'`` because the activity view only follows
     the chart → ``SqlaTable`` dependency edge (not legacy/other
     datasources). Rows with ``operation_type = 2`` are excluded.
     """
+    return _batch_datasets_used_by_charts({slice_id}).get(slice_id, [])
+
+
+def _batch_datasets_used_by_charts(
+    slice_ids: set[int],
+) -> dict[int, list[tuple[int, Window]]]:
+    """Batch form of :func:`_datasets_used_by_chart`. Returns
+    ``{slice_id: [(dataset_id, window), ...]}`` in a single query so the
+    dashboard-scope walker doesn't fire one query per chart on the
+    dashboard. The previous per-slice shape became O(n_charts) round-
+    trips, which dominated ``get_activity`` latency on dashboards with
+    rich history (profile run 2026-05-26 showed `_resolve_scope`
+    accounting for ~1.9s out of 4s p95).
+    """
+    if not slice_ids:
+        return {}
+
     # pylint: disable=import-outside-toplevel
     from sqlalchemy_continuum import version_class
 
@@ -200,11 +222,12 @@ def _datasets_used_by_chart(slice_id: int) -> list[tuple[int, Window]]:
         db.session.connection()
         .execute(
             sa.select(
+                slices_tbl.c.id,
                 slices_tbl.c.datasource_id,
                 slices_tbl.c.transaction_id,
                 slices_tbl.c.end_transaction_id,
             ).where(
-                slices_tbl.c.id == slice_id,
+                slices_tbl.c.id.in_(slice_ids),
                 slices_tbl.c.datasource_type == "table",
                 slices_tbl.c.operation_type != 2,
                 slices_tbl.c.datasource_id.is_not(None),
@@ -212,7 +235,10 @@ def _datasets_used_by_chart(slice_id: int) -> list[tuple[int, Window]]:
         )
         .all()
     )
-    return [(row[0], (row[1], row[2])) for row in rows]
+    grouped: dict[int, list[tuple[int, Window]]] = {}
+    for slice_id, dataset_id, start_tx, end_tx in rows:
+        grouped.setdefault(slice_id, []).append((dataset_id, (start_tx, end_tx)))
+    return grouped
 
 
 # ---- T007: Window intersection (pure) -------------------------------------
@@ -1141,11 +1167,14 @@ def _resolve_dashboard_scope(dashboard_id: int) -> list[EntityWindows]:
     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)))
-        # Fetch the chart's dataset history once per slice — it doesn't
-        # depend on which attachment window we're clipping against.
-        dataset_windows = _datasets_used_by_chart(slice_id)
+        dataset_windows = dataset_windows_by_slice.get(slice_id, [])
         for attachment in attachment_windows:
             for dataset_id, chart_dataset_window in dataset_windows:
                 if (