Compare commits

..

1 Commits

Author SHA1 Message Date
Evan Rusackas
987c5ada54 ci(frontend): track bundle size over time with benchmark-action
Superset's frontend bundle size is a recurring complaint, but until now
there's been no ongoing visibility into it -- only periodic manual cleanup
efforts. This adds a `bundle-size` job to superset-frontend.yml that builds
the real production bundle (npm run build), reduces its per-entrypoint
sizes to a few headline numbers, and tracks them over time via
benchmark-action/github-action-benchmark, posting a PR comment when a
change regresses past 110% of the last recorded baseline.

Reuses the already-built CI Docker image (same image sharded-jest-tests /
lint-frontend / etc. already download), so the only new cost is the
production webpack build itself -- and that's cheap after the first run:
webpack's persistent filesystem cache (already configured in
webpack.config.js) makes warm rebuilds ~20s locally vs several minutes
cold, confirmed by hand against this repo's real build.

webpack.config.js gains a BUNDLE_SIZE_STATS env-gated stats override
(mirrors the existing BUNDLE_ANALYZER pattern). The default `stats:
'minimal'` omits per-asset sizes entirely; `--stats=normal` includes them
but also serializes the full ~15k-module dependency graph, producing a
560+MB stats.json for this app -- large enough to exceed Node's max
string length on a plain fs.readFileSync. The env-gated override requests
just `{ assets: true, entrypoints: true }`, verified end-to-end against a
minimal synthetic webpack project (same webpack/webpack-cli versions) and
against real stats pulled from this repo's actual production build.

History storage deliberately avoids the gh-pages branch (benchmark-action's
usual default) since that branch is the live Helm chart index published by
superset-helm-release.yml, not free real estate. Instead uses
external-data-json-path with actions/cache: restored on every run (PR or
push) so PRs get a same-baseline comparison and regression comment, but
only saved back to the cache on push to master, so an unmerged PR's numbers
never become the shared baseline. No gh-pages branch is touched in any
code path, and no PAT/GitHub App is needed -- comment-on-alert only needs
the default per-job GITHUB_TOKEN.
2026-07-28 00:05:49 -07:00
8 changed files with 166 additions and 42 deletions

View File

@@ -195,3 +195,75 @@ jobs:
run: |
docker run --rm $TAG bash -c \
"npm run build-storybook && npx playwright install-deps && npx playwright install chromium && npm run test-storybook:ci"
bundle-size:
needs: frontend-build
if: needs.frontend-build.outputs.should-run == 'true'
runs-on: ubuntu-26.04
timeout-minutes: 15
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout Code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download Docker Image Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: docker-image
- name: Load Docker Image
run: |
zstd -d < docker-image.tar.zst | docker load
# Only ever pull the last recorded data point off the cache, keyed by
# run ID -- `restore-keys` prefix-matches the most recently created
# entry. Absent on the very first run ever; benchmark-action starts a
# fresh history in that case.
- name: Restore bundle size history
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: bundle-size-history.json
key: bundle-size-history-${{ github.run_id }}
restore-keys: |
bundle-size-history-
- name: Build production bundle with stats
run: |
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
docker run \
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
--rm $TAG \
bash -c \
"npm i && BUNDLE_SIZE_STATS=true npm run build -- --json=bundle-stats/stats.json"
- name: Summarize bundle size
run: |
node superset-frontend/scripts/bundle-size-summary.js \
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
rm -rf superset-frontend/bundle-stats
- name: Track bundle size
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
with:
tool: customSmallerIsBetter
output-file-path: bundle-size-summary.json
external-data-json-path: bundle-size-history.json
github-token: ${{ secrets.GITHUB_TOKEN }}
comment-on-alert: true
alert-threshold: "110%"
fail-on-alert: false
summary-always: true
# Only the trunk's own bundle size becomes the baseline everyone else
# is compared against -- an unmerged PR's numbers (including ones that
# regress on purpose to test something) never get persisted.
- name: Save bundle size history
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: bundle-size-history.json
key: bundle-size-history-${{ github.run_id }}

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env node
/*
* 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.
*/
// Reduces a webpack `--json` stats file down to the handful of headline
// numbers worth tracking over time, in the flat array format
// benchmark-action/github-action-benchmark expects for its
// "customSmallerIsBetter" tool. The full stats file also includes a
// `modules`/`chunks` graph across ~15k modules, which is enormous and not
// useful for this purpose, so we only ever read `entrypoints`.
//
// Usage: node scripts/bundle-size-summary.js <path-to-stats.json>
const fs = require('fs');
// Entrypoints worth tracking: the two user-facing app shells. `menu`,
// `preamble`, `theme`, and `service-worker` are small, low-variance
// infrastructure chunks, not where bundle bloat actually shows up.
const TRACKED_ENTRYPOINTS = ['spa', 'embedded'];
function entrypointSizeByExt(entrypoint, ext) {
return (entrypoint.assets || [])
.filter(asset => asset.name.endsWith(ext))
.reduce((total, asset) => total + asset.size, 0);
}
function main() {
const statsPath = process.argv[2];
if (!statsPath) {
console.error('Usage: bundle-size-summary.js <path-to-stats.json>');
process.exit(1);
}
const stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
const { entrypoints } = stats;
if (!entrypoints) {
console.error(
'stats.json has no `entrypoints` key -- was it generated with ' +
'`--stats=normal` (or richer)? `minimal`/`errors-only` stats omit it.',
);
process.exit(1);
}
const results = [];
TRACKED_ENTRYPOINTS.forEach(name => {
const entrypoint = entrypoints[name];
if (!entrypoint) return;
results.push({
name: `${name} entrypoint (JS)`,
unit: 'bytes',
value: entrypointSizeByExt(entrypoint, '.js'),
});
results.push({
name: `${name} entrypoint (CSS)`,
unit: 'bytes',
value: entrypointSizeByExt(entrypoint, '.css'),
});
});
console.log(JSON.stringify(results, null, 2));
}
main();

View File

@@ -738,4 +738,14 @@ const smp = new SpeedMeasurePlugin({
disable: !measure,
});
// Emits per-asset/entrypoint sizes via `--json` (the default `stats: 'minimal'`
// above omits both). Not `normal`/`detailed` stats: those also serialize the
// full ~15k-module dependency graph, which is hundreds of MB for this app --
// large enough to exceed Node's max string length when read back with
// `fs.readFileSync`. Used by scripts/bundle-size-summary.js in CI.
// e.g. BUNDLE_SIZE_STATS=true npm run build -- --json=stats.json
if (process.env.BUNDLE_SIZE_STATS) {
config.stats = { all: false, assets: true, entrypoints: true };
}
module.exports = smp.wrap(config);

View File

@@ -318,7 +318,7 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
# another temporal filter. A new filter based on the value of
# the granularity will be added later in the code.
# In practice, this is replacing the previous default temporal filter.
if filter_to_remove and is_adhoc_column(filter_to_remove): # type: ignore
if is_adhoc_column(filter_to_remove): # type: ignore
filter_to_remove = filter_to_remove.get("sqlExpression")
if filter_to_remove:

View File

@@ -206,9 +206,11 @@ class QueryCacheManager:
)
query_cache.status = QueryStatus.SUCCESS
query_cache.is_loaded = True
query_cache.is_cached = True
query_cache.is_cached = cache_value is not None
query_cache.sql_rowcount = cache_value.get("sql_rowcount", None)
query_cache.cache_dttm = cache_value["dttm"]
query_cache.cache_dttm = (
cache_value["dttm"] if cache_value is not None else None
)
query_cache.queried_dttm = cache_value.get(
"queried_dttm", cache_value.get("dttm")
)

View File

@@ -534,7 +534,6 @@ class DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
],
) -> list[SupersetError]:
errors: list[SupersetError] = []
connect_args: dict[str, Any] = {}
if extra := json.loads(properties.get("extra")): # type: ignore
engine_params = extra.get("engine_params", {})
connect_args = engine_params.get("connect_args", {})

View File

@@ -1235,7 +1235,6 @@ class PrestoEngineSpec(PrestoBaseEngineSpec):
all_columns: list[ResultSetColumnType] = []
expanded_columns = []
current_array_level = None
unnested_rows: dict[int, int] = defaultdict(int)
while to_process:
column, level = to_process.popleft()
if column["column_name"] not in [
@@ -1249,7 +1248,7 @@ class PrestoEngineSpec(PrestoBaseEngineSpec):
# added by the first. every time we change a level in the nested arrays
# we reinitialize this.
if level != current_array_level:
unnested_rows = defaultdict(int)
unnested_rows: dict[int, int] = defaultdict(int)
current_array_level = level
name = column["column_name"]

View File

@@ -532,42 +532,6 @@ class TestQueryContextFactory:
assert query_object.columns == ["ds", "other_col"]
def test_apply_granularity_no_filter_to_remove(self):
"""No x-axis and no temporal filters leaves the filters untouched."""
query_object = Mock(spec=QueryObject)
query_object.granularity = "P1D"
query_object.columns = ["other_col"]
query_object.post_processing = []
query_object.filter = [{"col": "other_col", "op": "==", "val": "value"}]
datasource = Mock()
datasource.columns = [{"column_name": "ds", "is_dttm": True}]
self.factory._apply_granularity(query_object, {}, datasource)
assert query_object.filter == [{"col": "other_col", "op": "==", "val": "value"}]
def test_apply_granularity_with_adhoc_temporal_filter(self):
"""An adhoc temporal filter is matched on its SQL expression."""
adhoc_column = {"label": "ds_expr", "sqlExpression": "DATE(ds)"}
query_object = Mock(spec=QueryObject)
query_object.granularity = "P1D"
query_object.columns = ["other_col"]
query_object.post_processing = []
query_object.filter = [
{"col": adhoc_column, "op": "TEMPORAL_RANGE", "val": "a : b"},
{"col": "DATE(ds)", "op": "TEMPORAL_RANGE", "val": "a : b"},
]
datasource = Mock()
datasource.columns = [{"column_name": "ds", "is_dttm": True}]
self.factory._apply_granularity(query_object, {}, datasource)
assert query_object.filter == [
{"col": adhoc_column, "op": "TEMPORAL_RANGE", "val": "a : b"}
]
def test_apply_filters_with_time_range(self):
"""Test _apply_filters with time_range"""
query_object = Mock(spec=QueryObject)