mirror of
https://github.com/apache/superset.git
synced 2026-07-11 17:25:31 +00:00
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
219 lines
8.8 KiB
Python
219 lines
8.8 KiB
Python
# 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.
|
|
"""Output-safety caps on the change-record diff engine.
|
|
|
|
``version_changes`` is an audit log, not a content store. A single edit must
|
|
not be able to write an unbounded value or an unbounded number of rows — a
|
|
200 KB ``params``/SQL blob, a 2000-element list edit, or a 1000-key dict
|
|
rewrite would otherwise bloat both the table and the activity stream. These
|
|
tests pin :func:`cap_records` (applied at persistence time) against exactly
|
|
those cases, and confirm ordinary small edits pass through untouched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from superset.utils import json
|
|
from superset.versioning.diff import (
|
|
cap_records,
|
|
ChangeRecord,
|
|
diff_slice_params,
|
|
MAX_RECORDS_PER_FIELD,
|
|
MAX_VALUE_BYTES,
|
|
)
|
|
|
|
|
|
def _bytes(value: object) -> int:
|
|
return len(json.dumps(value, default=str))
|
|
|
|
|
|
def _max_value_bytes(records: list[ChangeRecord]) -> int:
|
|
return max(
|
|
(_bytes(r.from_value) + _bytes(r.to_value) for r in records),
|
|
default=0,
|
|
)
|
|
|
|
|
|
def test_small_edits_pass_through_uncapped() -> None:
|
|
"""An ordinary handful of small param edits is returned verbatim — the
|
|
caps must not perturb the common case."""
|
|
records = diff_slice_params(
|
|
{"time_range": "Last week", "viz_type": "table"},
|
|
{"time_range": "Last month", "viz_type": "pie"},
|
|
)
|
|
capped = cap_records(records)
|
|
assert len(capped) == len(records) == 2
|
|
assert all("__truncated__" not in str(r.to_value) for r in capped)
|
|
assert all("__collapsed__" not in str(r.to_value) for r in capped)
|
|
|
|
|
|
def test_oversized_value_is_truncated_to_a_marker() -> None:
|
|
"""A value past MAX_VALUE_BYTES is replaced by a bounded marker that records
|
|
the original size and a preview, instead of writing the whole blob."""
|
|
big = "a" * (MAX_VALUE_BYTES * 4)
|
|
capped = cap_records(diff_slice_params({"sql": "x"}, {"sql": big}))
|
|
assert len(capped) == 1
|
|
marker = capped[0].to_value
|
|
assert marker["__truncated__"] is True
|
|
assert marker["original_bytes"] >= MAX_VALUE_BYTES * 4
|
|
assert len(marker["preview"]) <= 256
|
|
assert _bytes(marker) <= MAX_VALUE_BYTES + 600 # marker itself stays small
|
|
|
|
|
|
def test_list_explosion_is_collapsed() -> None:
|
|
"""Editing a 2000-element list emits one record per element pre-cap; the cap
|
|
collapses the whole field to a single summary record."""
|
|
pre = {"adhoc_filters": [{"col": f"c{i}", "val": i} for i in range(2000)]}
|
|
post = {"adhoc_filters": [{"col": f"c{i}", "val": i + 1} for i in range(2000)]}
|
|
raw = diff_slice_params(pre, post)
|
|
assert len(raw) > MAX_RECORDS_PER_FIELD # the explosion exists pre-cap
|
|
capped = cap_records(raw)
|
|
assert len(capped) == 1
|
|
assert capped[0].to_value == {"__collapsed__": len(raw)}
|
|
assert capped[0].path == ["params"]
|
|
|
|
|
|
def test_wide_dict_rewrite_is_collapsed() -> None:
|
|
"""A 1000-key dict rewrite under one field collapses to a single record."""
|
|
pre = {"query_context": {f"k{i}": i for i in range(1000)}}
|
|
post = {"query_context": {f"k{i}": i + 1 for i in range(1000)}}
|
|
capped = cap_records(diff_slice_params(pre, post))
|
|
assert len(capped) == 1
|
|
assert capped[0].to_value["__collapsed__"] == 1000
|
|
|
|
|
|
def test_distinct_fields_are_capped_independently() -> None:
|
|
"""The record-count cap is per top-level field: a blown-up field collapses
|
|
while a sibling small edit on a different field survives intact."""
|
|
pre = {
|
|
"adhoc_filters": [{"c": i} for i in range(2000)],
|
|
"time_range": "Last week",
|
|
}
|
|
post = {
|
|
"adhoc_filters": [{"c": i + 1} for i in range(2000)],
|
|
"time_range": "Last month",
|
|
}
|
|
capped = cap_records(diff_slice_params(pre, post))
|
|
# one collapsed record for params (filters live under params) + the
|
|
# time_range edit, also under params -> all collapse into the params bucket.
|
|
assert all(
|
|
_bytes(r.from_value) + _bytes(r.to_value) <= MAX_VALUE_BYTES + 600
|
|
for r in capped
|
|
)
|
|
assert len(capped) <= MAX_RECORDS_PER_FIELD
|
|
|
|
|
|
def test_caps_bound_every_record_from_diff_slice_params() -> None:
|
|
"""End-to-end invariant: after capping, no record exceeds the value or
|
|
count bounds, for the adversarial inputs the example gallery can't reach."""
|
|
pre = {"sql": "x", "adhoc_filters": [{"c": i} for i in range(2000)]}
|
|
post = {
|
|
"sql": "z" * (MAX_VALUE_BYTES * 10),
|
|
"adhoc_filters": [{"c": i + 1} for i in range(2000)],
|
|
}
|
|
capped = cap_records(diff_slice_params(pre, post))
|
|
assert len(capped) <= MAX_RECORDS_PER_FIELD
|
|
assert _max_value_bytes(capped) <= MAX_VALUE_BYTES + 600
|
|
|
|
|
|
# Heavy-but-legitimate chart params, representative of the variety the chart
|
|
# gallery produces — a wide table, a pivot, a multi-layer geospatial config.
|
|
# These exist to prove the caps are tuned NOT to fire on real charts: a sweep
|
|
# over the example gallery found every viz type's edit well under both bounds,
|
|
# so a normal edit must yield individual, readable records — never a collapse
|
|
# or truncation marker. If a future cap-threshold tweak starts degrading real
|
|
# charts, these break.
|
|
_REALISTIC_PARAMS: dict[str, dict[str, Any]] = {
|
|
"wide_table": {
|
|
"viz_type": "table",
|
|
"query_mode": "aggregate",
|
|
"groupby": [f"dim_{i}" for i in range(15)],
|
|
"metrics": [
|
|
{
|
|
"label": f"m_{i}",
|
|
"expressionType": "SIMPLE",
|
|
"column": {"column_name": f"c_{i}"},
|
|
"aggregate": "SUM",
|
|
}
|
|
for i in range(8)
|
|
],
|
|
"adhoc_filters": [
|
|
{
|
|
"clause": "WHERE",
|
|
"subject": f"dim_{i}",
|
|
"operator": "==",
|
|
"comparator": f"v_{i}",
|
|
"expressionType": "SIMPLE",
|
|
}
|
|
for i in range(5)
|
|
],
|
|
"time_range": "Last quarter",
|
|
"row_limit": 1000,
|
|
"table_timestamp_format": "smart_date",
|
|
},
|
|
"pivot": {
|
|
"viz_type": "pivot_table_v2",
|
|
"groupbyRows": [f"row_{i}" for i in range(6)],
|
|
"groupbyColumns": [f"col_{i}" for i in range(4)],
|
|
"metrics": [
|
|
{
|
|
"label": f"agg_{i}",
|
|
"expressionType": "SIMPLE",
|
|
"column": {"column_name": f"c_{i}"},
|
|
"aggregate": "AVG",
|
|
}
|
|
for i in range(5)
|
|
],
|
|
"aggregateFunction": "Sum",
|
|
"valueFormat": "SMART_NUMBER",
|
|
},
|
|
"geospatial": {
|
|
"viz_type": "deck_multi",
|
|
"deck_slices": [{"layer": f"l_{i}", "opacity": 0.8} for i in range(6)],
|
|
"viewport": {"longitude": -122.4, "latitude": 37.8, "zoom": 11.2},
|
|
"spatial": {"latCol": "lat", "lonCol": "lon", "type": "latlong"},
|
|
"color_picker": {"r": 14, "g": 96, "b": 245, "a": 1},
|
|
},
|
|
}
|
|
|
|
|
|
def test_realistic_chart_edits_are_not_capped() -> None:
|
|
"""A normal edit to a heavy-but-legitimate chart of each shape yields
|
|
individual records — none collapsed, none truncated — so the caps never
|
|
degrade real-world activity entries."""
|
|
for name, params in _REALISTIC_PARAMS.items():
|
|
edited = {**params, "row_limit": 5000, "time_range": "Last year"}
|
|
records = diff_slice_params(params, edited)
|
|
capped = cap_records(records)
|
|
assert capped == records, f"{name}: caps must not alter a normal edit"
|
|
assert capped, f"{name}: a real edit must still produce records"
|
|
for r in capped:
|
|
assert "__collapsed__" not in str(r.to_value), f"{name}: collapsed"
|
|
assert "__truncated__" not in str(r.to_value), f"{name}: truncated"
|
|
|
|
|
|
def test_full_rewrite_of_realistic_charts_stays_under_caps() -> None:
|
|
"""Even replacing a heavy chart's params wholesale (every field changes at
|
|
once) stays within both bounds for legitimate shapes — the caps headroom
|
|
is real, not just exceeded-by-construction in the adversarial tests."""
|
|
empty: dict[str, Any] = {}
|
|
for name, params in _REALISTIC_PARAMS.items():
|
|
capped = cap_records(diff_slice_params(empty, params))
|
|
assert len(capped) <= MAX_RECORDS_PER_FIELD, f"{name}: count over cap"
|
|
assert _max_value_bytes(capped) <= MAX_VALUE_BYTES, f"{name}: value over cap"
|