diff --git a/marimo/_plugins/ui/_impl/tables/narwhals_table.py b/marimo/_plugins/ui/_impl/tables/narwhals_table.py index a2a82dc5ed8..e60c1f6132b 100644 --- a/marimo/_plugins/ui/_impl/tables/narwhals_table.py +++ b/marimo/_plugins/ui/_impl/tables/narwhals_table.py @@ -699,6 +699,10 @@ def get_sample_values(self, column: str) -> list[str | int | float]: # Sample 3 values from the column SAMPLE_SIZE = 3 + # Cap nested list/dict serialization depth. Unbounded recursion (or + # even json.dumps on pathological nested structs) can stall dataset + # registration for deeply nested Polars Struct columns (#9378). + MAX_NESTING_DEPTH = 5 try: def _json_default(o: Any) -> str: @@ -706,6 +710,26 @@ def _json_default(o: Any) -> str: return o.name return str(o) + def _with_depth_limit(value: Any, depth: int = 0) -> Any: + """Return a structure safe for json.dumps / display. + + At max depth, emit a fixed sentinel rather than str(value): + str() on large list/dict trees still walks the full remaining + container (issue #9378 branching payloads). + """ + if depth >= MAX_NESTING_DEPTH: + if isinstance(value, (list, dict)): + return "..." + return value + if isinstance(value, list): + return [_with_depth_limit(v, depth + 1) for v in value] + if isinstance(value, dict): + return { + k: _with_depth_limit(v, depth + 1) + for k, v in value.items() + } + return value + def to_primitive(value: Any) -> str | int | float: if isinstance(value, Enum): return value.name @@ -713,7 +737,8 @@ def to_primitive(value: Any) -> str | int | float: return value if isinstance(value, (list, dict)): try: - return json.dumps(value, default=_json_default) + limited = _with_depth_limit(value) + return json.dumps(limited, default=_json_default) except (TypeError, ValueError): return str(value) return str(value) diff --git a/tests/_plugins/ui/_impl/tables/test_narwhals.py b/tests/_plugins/ui/_impl/tables/test_narwhals.py index 53a3c222a4e..2611330f55f 100644 --- a/tests/_plugins/ui/_impl/tables/test_narwhals.py +++ b/tests/_plugins/ui/_impl/tables/test_narwhals.py @@ -1578,6 +1578,37 @@ def build_payload(row_idx: int, depth: int): @pytest.mark.skipif(not HAS_DEPS, reason="polars not installed") +@pytest.mark.skipif(not HAS_DEPS, reason="polars not installed") +def test_get_sample_values_branching_payload_is_bounded() -> None: + """Wide branching nests must not explode work via str() at the depth cap.""" + import polars as pl + + def branch(depth: int, width: int = 8) -> Any: + if depth == 0: + return {"leaf": "x" * 20, "n": 1} + return {f"b{i}": branch(depth - 1, width) for i in range(width)} + + # width^depth without a real cap is enormous; with sentinel cap work is O(width^MAX). + payload = branch(6, width=6) + df = pl.DataFrame({"payload": [payload, payload, payload]}) + manager = NarwhalsTableManager.from_dataframe( + nw.from_native(df, eager_only=True) + ) + + t0 = time.perf_counter() + samples = manager.get_sample_values("payload") + elapsed = time.perf_counter() - t0 + + assert len(samples) == 3 + assert elapsed < 1.0, ( + f"get_sample_values took {elapsed:.2f}s (expected < 1s)" + ) + for s in samples: + assert isinstance(s, str) + assert "..." in s + assert len(s) < 250_000 + + def test_get_sample_values_nested_struct_is_json() -> None: import polars as pl @@ -1730,6 +1761,32 @@ def test_calculate_top_k_rows_with_all_special_floats(df: Any) -> None: ] +@pytest.mark.skipif( + not DependencyManager.pandas.has(), + reason="pandas required for nested sample depth test", +) +def test_get_sample_values_nested_depth_limit() -> None: + """Deeply nested list/dict samples must finish quickly (#9378 / #9383).""" + import pandas as pd + + def nest(depth: int) -> object: + v: object = "leaf" + for _ in range(depth): + v = {"k": v} + return v + + df = pd.DataFrame({"deep": [nest(20)]}) + manager = NarwhalsTableManager.from_dataframe(df) + t0 = time.perf_counter() + sample = manager.get_sample_values("deep") + elapsed = time.perf_counter() - t0 + assert elapsed < 1.0, f"nested sample too slow: {elapsed:.3f}s" + assert len(sample) == 1 + assert isinstance(sample[0], str) + # depth limit should stringify remaining nesting rather than exploding + assert "leaf" in sample[0] or sample[0].startswith("{") + + def _normalize_result(result: list[tuple[Any, int]]) -> list[tuple[Any, int]]: """Normalize None and NaN values for comparison.""" out: list[tuple[Any, int]] = []