Compare commits

...

3 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
PRATHAMESH HUKKERI
26b6f7bb5d fix(dashboard): preserve refresh_frequency when absent from save data (#42354)
Co-authored-by: Prathamesh Hukkeri <prathamesh04@users.noreply.github.com>
2026-07-27 23:13:11 -07:00
Phuc Hung Nguyen
a10c3f0b1d test(result_set): add regression test for empty result set column metadata (#35962)
Co-authored-by: Phuc Hung Nguyen <phucnguyen@geotab.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 23:08:59 -07:00
6 changed files with 263 additions and 1 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

@@ -397,7 +397,8 @@ class DashboardDAO(BaseDAO[Dashboard]):
md["color_namespace"] = data.get("color_namespace")
md["expanded_slices"] = data.get("expanded_slices", {})
md["refresh_frequency"] = data.get("refresh_frequency", 0)
if "refresh_frequency" in data:
md["refresh_frequency"] = data["refresh_frequency"]
md["color_scheme"] = data.get("color_scheme", "")
md["label_colors"] = data.get("label_colors", {})
md["shared_label_colors"] = data.get("shared_label_colors", [])

View File

@@ -24,6 +24,7 @@ from superset.connectors.sqla.models import Database, SqlaTable
from superset.daos.dashboard import DashboardDAO
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.utils import json
from tests.unit_tests.conftest import with_feature_flags
@@ -117,3 +118,53 @@ def test_set_dash_metadata_preserves_soft_deleted_members(
)
# And the position slot kept its UUID rather than being nulled.
assert positions["CHART-trashed"]["meta"]["uuid"] == str(trashed_chart.uuid)
def test_set_dash_metadata_preserves_refresh_frequency(session: Session) -> None:
"""set_dash_metadata must not reset refresh_frequency when absent from data.
Regression test for #42116: ``data.get("refresh_frequency", 0)`` would
unconditionally overwrite the existing value with 0 whenever the caller
did not include ``refresh_frequency`` in the data dict.
"""
Dashboard.metadata.create_all(session.get_bind())
dashboard = Dashboard(
dashboard_title="refresh_test_dash",
json_metadata=json.dumps({"refresh_frequency": 30}),
)
db.session.add(dashboard)
db.session.flush()
# Simulate a save that does NOT include refresh_frequency
# (e.g. changing only the title via the PropertiesModal).
DashboardDAO.set_dash_metadata(dashboard, {"color_scheme": "superset"})
md = json.loads(dashboard.json_metadata)
assert md["refresh_frequency"] == 30, (
"refresh_frequency should be preserved when not present in data"
)
def test_set_dash_metadata_updates_refresh_frequency_when_present(
session: Session,
) -> None:
"""set_dash_metadata must update refresh_frequency when it IS in data."""
Dashboard.metadata.create_all(session.get_bind())
dashboard = Dashboard(
dashboard_title="refresh_test_dash_2",
json_metadata=json.dumps({"refresh_frequency": 30}),
)
db.session.add(dashboard)
db.session.flush()
# Simulate a save that explicitly sets refresh_frequency to 0.
DashboardDAO.set_dash_metadata(
dashboard, {"refresh_frequency": 0, "color_scheme": "superset"}
)
md = json.loads(dashboard.json_metadata)
assert md["refresh_frequency"] == 0, (
"refresh_frequency should be updated when present in data"
)

View File

@@ -571,3 +571,53 @@ def test_stringify_values_non_serializable_dict_falls_back_to_str() -> None:
# Must not raise — falls back to str()
result = stringify_values(data)
assert result[0] == str({"key": _Unserializable()})
def test_empty_result_set_preserves_column_metadata() -> None:
"""
Test that column metadata is preserved when query returns zero rows.
When a query returns no data but has a valid cursor description, the
column names and types from cursor_description should be preserved
in the result set. This allows downstream consumers (like the UI)
to display column headers even for empty result sets.
"""
data: DbapiResult = []
description = [
("id", "int", None, None, None, None, True),
("name", "varchar", None, None, None, None, True),
("created_at", "timestamp", None, None, None, None, True),
]
result_set = SupersetResultSet(
data,
description, # type: ignore
BaseEngineSpec,
)
# Verify column count
assert len(result_set.columns) == 3
# Verify column names are preserved
column_names = [col["column_name"] for col in result_set.columns]
assert column_names == ["id", "name", "created_at"]
assert result_set.columns[0]["type"] == BaseEngineSpec.get_datatype(
description[0][1]
)
assert result_set.columns[1]["type"] == BaseEngineSpec.get_datatype(
description[1][1]
)
assert result_set.columns[2]["type"] == BaseEngineSpec.get_datatype(
description[2][1]
)
# Verify the PyArrow table has the correct schema
assert result_set.table.num_rows == 0
assert len(result_set.table.column_names) == 3
assert list(result_set.table.column_names) == ["id", "name", "created_at"]
# Verify DataFrame conversion works
df = result_set.to_pandas_df()
assert len(df) == 0
assert list(map(str, df.columns)) == ["id", "name", "created_at"]