diff --git a/buckaroo/customizations/pd_stats_v2.py b/buckaroo/customizations/pd_stats_v2.py
index 68d281fb7..bb59792d2 100644
--- a/buckaroo/customizations/pd_stats_v2.py
+++ b/buckaroo/customizations/pd_stats_v2.py
@@ -101,6 +101,25 @@ def _type(is_bool: bool, is_numeric: bool, is_float: bool,
return "obj"
+def with_type(flags: dict) -> dict:
+ """``flags``, the ``is_*`` keys a typing stat returns, plus the ``_type``
+ derived from them through the ``_type`` stat."""
+ return {**flags, '_type': _type(**{key.name: flags[key.name] for key in _type._stat_func.requires})}
+
+
+def schema_stats(ser: pd.Series) -> dict:
+ """The stats a column's dtype alone determines: ``dtype``, the ``is_*``
+ flags and ``_type``. ``typing_stats`` runs on a zero-row slice, so no value
+ is read, and ``memory_usage`` is left out because it measures the data.
+
+ An ``object`` column gives the typing nothing to go on, and pandas calls an
+ empty one a string, so every object column types as ``string`` here. Full
+ stats look at the values and may say ``obj``."""
+ flags = typing_stats(ser.iloc[:0])
+ del flags['memory_usage']
+ return with_type(flags)
+
+
# ============================================================
# Base Summary Stats (replaces DefaultSummaryStats ColAnalysis)
# ============================================================
diff --git a/buckaroo/customizations/pl_stats_v2.py b/buckaroo/customizations/pl_stats_v2.py
index 687080eae..5e5392a06 100644
--- a/buckaroo/customizations/pl_stats_v2.py
+++ b/buckaroo/customizations/pl_stats_v2.py
@@ -21,7 +21,7 @@
from buckaroo.pluggable_analysis_framework.column_filters import is_numeric_not_bool
# Reused unchanged from pd_stats_v2 — operate on stat dict, not raw series
-from buckaroo.customizations.pd_stats_v2 import (_type, computed_default_summary_stats, histogram, cleaning_gen_ops, BaseSummaryResult, NumericStatsResult, HistogramSeriesResult)
+from buckaroo.customizations.pd_stats_v2 import (_type, computed_default_summary_stats, histogram, cleaning_gen_ops, with_type, BaseSummaryResult, NumericStatsResult, HistogramSeriesResult)
# ============================================================
@@ -44,16 +44,27 @@ def pl_orig_col_name(ser: RawSeries) -> Any:
'is_time': bool, 'is_decimal': bool, 'is_binary': bool, 'memory_usage': int})
-@stat()
-def pl_typing_stats(ser: RawSeries) -> PlTypingResult:
- """Compute dtype and type flags for a polars column."""
- dt = ser.dtype
+def pl_dtype_typing(dt: pl.DataType) -> dict:
+ """dtype string and type flags for a polars dtype. Needs a schema only,
+ which is what ``pl_schema_stats`` has to go on."""
return {'dtype': str(dt), 'is_numeric': dt.is_numeric() and dt.base_type() is not pl.Decimal,
'is_integer': dt.is_integer(), 'is_float': dt.is_float(), 'is_bool': dt == pl.Boolean,
'is_datetime': dt.is_temporal() and dt not in (pl.Duration, pl.Time), 'is_timedelta': dt == pl.Duration,
'is_string': dt in (pl.Utf8, pl.String), 'is_categorical': dt == pl.Categorical or isinstance(dt, pl.Enum),
'is_period': False, 'is_interval': False, 'is_time': dt == pl.Time, 'is_decimal': dt.base_type() is pl.Decimal,
- 'is_binary': dt == pl.Binary, 'memory_usage': ser.estimated_size()}
+ 'is_binary': dt == pl.Binary}
+
+
+@stat()
+def pl_typing_stats(ser: RawSeries) -> PlTypingResult:
+ """Compute dtype and type flags for a polars column."""
+ return {**pl_dtype_typing(ser.dtype), 'memory_usage': ser.estimated_size()}
+
+
+def pl_schema_stats(ser: pl.Series) -> dict:
+ """The stats a polars column's dtype alone determines: ``dtype``, the
+ ``is_*`` flags and ``_type``. Reads ``ser.dtype`` and no value."""
+ return with_type(pl_dtype_typing(ser.dtype))
# ============================================================
diff --git a/buckaroo/customizations/xorq_stats_v2.py b/buckaroo/customizations/xorq_stats_v2.py
index fe613f302..7f5fcaa63 100644
--- a/buckaroo/customizations/xorq_stats_v2.py
+++ b/buckaroo/customizations/xorq_stats_v2.py
@@ -138,6 +138,16 @@ def _type(is_bool: bool, is_integer: bool, is_float: bool, is_datetime: bool, is
return "obj"
+def schema_stats(dtype: str) -> dict:
+ """The stats a column's dtype alone determines: ``dtype``, the ``is_*``
+ flags and ``_type``. This is what the xorq dataflow's schema tier
+ publishes, derived through the same stats the pipeline runs."""
+ flags = typing_stats(dtype)
+ return {"dtype": dtype, **flags, "_type": _type(
+ flags["is_bool"], flags["is_integer"], flags["is_float"], flags["is_datetime"],
+ flags["is_string"])}
+
+
# ============================================================
# Batched aggregates — one ibis.Expr each, folded into the batch query
# ============================================================
diff --git a/buckaroo/dataflow/dataflow.py b/buckaroo/dataflow/dataflow.py
index 271e86ca4..ea1650d9d 100644
--- a/buckaroo/dataflow/dataflow.py
+++ b/buckaroo/dataflow/dataflow.py
@@ -11,6 +11,7 @@
from ..serialization_utils import pd_to_obj, sd_to_parquet_b64, project_sd
from buckaroo.pluggable_analysis_framework.utils import (filter_analysis)
from buckaroo.pluggable_analysis_framework.df_stats_v2 import DfStatsV2
+from buckaroo.pluggable_analysis_framework.stat_units import Fragment, StatAccumulator, StatState, StatUnit
from .autocleaning import SentinelAutocleaning
from .dataflow_extras import (exception_protect, Sampling)
from .styling_core import (
@@ -27,6 +28,63 @@
from .df_types import DataFrameT
from .sd_cache import hash_chain, split_chain_by_scope
+# Content levels a dataflow can be built at. ``full`` runs the summary-stats
+# pipeline. ``schema`` publishes identity and typing keys derived from the
+# schema alone and runs no data query (backends opt in, see ``_get_schema_sd``).
+STATS_TIERS = ("full", "schema")
+
+
+def assemble_merged_sd(init_sd: InitSD, cleaned_sd: SDType, raw_sd: SDType, processed_sd: SDType,
+ processed_df: TAny, chains: TDict[str, List[TAny]],
+ clean_sd: Optional[SDType] = None, filt_sd: Optional[SDType] = None) -> SDType:
+ """Build the wire ``merged_sd`` from the pieces ``_merged_sd`` reads.
+
+ Bare keys come from the raw scope's sd (``raw_sd``, computed on
+ ``sampled_df``) merged under ``init_sd`` (keyed by original column name,
+ so rewritten here), ``cleaned_sd`` and ``processed_sd``. ``cleaned_*`` keys
+ are layered on top from ``clean_sd`` when cleaning is active, and
+ ``filtered_*`` keys from ``filt_sd`` when a search filter is active.
+
+ Pure: the inputs are not mutated, so a caller holding an accumulating
+ ``raw_sd`` can assemble it repeatedly without the traitlets cascade.
+ ``chains`` is ``split_chain_by_scope(operations)``.
+ """
+ if processed_df is None:
+ # on initial startup
+ return merge_sds(init_sd, cleaned_sd, raw_sd, processed_sd)
+
+ # Gate each prefixed layer on a chain-shape diff between scopes.
+ # ``cleaned_*`` fires when the clean chain has ops the raw chain
+ # doesn't (cleaning is on); ``filtered_*`` fires when the filt
+ # chain has ops the clean chain doesn't (at least one
+ # quick-command op is present, i.e. a search filter is on).
+ # Keying off ``filt_sd_key != raw_sd_key`` would also fire
+ # ``filtered_*`` for cleaning-only states, mislabelling cleaned
+ # stats as filtered.
+ cleaning_active = chains['clean'] != chains['raw']
+ filter_active = chains['filt'] != chains['clean']
+
+ #we do this to get rewrtten keys for init_sd
+ rewritten_init_sd = merge_sd_overrides({}, processed_df, init_sd)
+ intermediate_sd = merge_sds(rewritten_init_sd, cleaned_sd, raw_sd)
+ base = merge_sd_overrides(intermediate_sd, processed_df, processed_sd)
+
+ # Layer ``cleaned_*`` keys on top when cleaning is active.
+ if cleaning_active and clean_sd:
+ for col, stats in clean_sd.items():
+ col_dict = base.setdefault(col, {})
+ for stat_name, val in stats.items():
+ col_dict[f'cleaned_{stat_name}'] = val
+
+ # Layer ``filtered_*`` keys on top when a filter is active.
+ if filter_active and filt_sd:
+ for col, stats in filt_sd.items():
+ col_dict = base.setdefault(col, {})
+ for stat_name, val in stats.items():
+ col_dict[f'filtered_{stat_name}'] = val
+
+ return base
+
class DfTrait(Any):
"""Any-trait for values that may contain a pandas/polars DataFrame.
@@ -82,15 +140,22 @@ class DfStats(Protocol):
``DfStatsV2`` (pandas), ``PlDfStatsV2`` (polars) and ``XorqDfStatsV2``
share this surface but no base class. The frame argument is ``Any``
because each executor accepts only its own backend's frame type.
+
+ ``run=False`` builds one without computing anything, for a caller that runs
+ ``plan(state)`` and ``run(unit, acc)`` itself (see ``stat_units``).
"""
sdf: TAny
errs: TAny
ap: TAny
+ state: StatState
def __init__(self, df: TAny, col_analysis_objs: TAny, /, operating_df_name: TAny = ...,
- debug: bool = ..., skip_columns: TAny = ...) -> None: ...
+ debug: bool = ..., skip_columns: TAny = ..., run: bool = ...) -> None: ...
@classmethod
def verify_analysis_objects(cls, col_analysis_objs: TAny, /) -> None: ...
def add_analysis(self, a_obj: TAny, /) -> None: ...
+ def plan(self, state: Optional[StatState] = ...) -> List[StatUnit]: ...
+ def new_accumulator(self, state: Optional[StatState] = ...) -> StatAccumulator: ...
+ def run(self, unit: StatUnit, acc: StatAccumulator) -> Fragment: ...
class DataFlow(ABCDataflow[DataFrameT], Generic[DataFrameT]):
@@ -161,6 +226,10 @@ def __init__(self, raw_df):
# are id()-based cache keys and the dead "foo"/"bar" test branches below,
# all of which treat None and [] alike).
analysis_klasses: List[Type[ColAnalysis]] = []
+ # The content level the summary-stats cascade runs at (see STATS_TIERS).
+ # Instance-level on CustomizableDataflow; ``full`` here so the bare
+ # pipeline's dedupe key and cache keys can read it.
+ stats_tier: str = "full"
summary_sd = Any()
df_meta = Any()
@@ -312,7 +381,7 @@ def _get_summary_sd(self, processed_df: DataFrameT) -> Tuple[SDType, TAny]:
ret_summary[col] = {}
return ret_summary, {}
- _summary_sd_cache_key = (None, None)
+ _summary_sd_cache_key = (None, None, None)
@observe('processed_result', 'analysis_klasses')
@exception_protect('summary_sd-protector')
@@ -322,7 +391,8 @@ def _summary_sd(self, change):
# tuple wrapper, which makes this observer fire twice per widget
# construction even when processed_df identity is unchanged.
# Skip when neither the dataframe nor analysis_klasses has actually
- # changed since the last run. See issue #709.
+ # changed since the last run. See issue #709. The tier is part of the
+ # key so a tier change reruns the summary even on the same frame.
df = self.processed_df
if df is None:
# Nothing to summarize before the pipeline has produced a frame.
@@ -330,9 +400,9 @@ def _summary_sd(self, change):
# narrows Optional[DataFrameT] -> DataFrameT for _get_summary_sd.
return
klasses = self.analysis_klasses
- if (id(df), id(klasses)) == self._summary_sd_cache_key:
+ if (id(df), id(klasses), self.stats_tier) == self._summary_sd_cache_key:
return
- self._summary_sd_cache_key = (id(df), id(klasses))
+ self._summary_sd_cache_key = (id(df), id(klasses), self.stats_tier)
result_summary_sd, errs = self._get_summary_sd(df)
self.summary_sd = result_summary_sd
self.errs = errs
@@ -390,7 +460,11 @@ def __init__(self, orig_df, debug=False,
pinned_rows:Union[Literal[None], PinnedRowConfig]=None, extra_grid_config=None,
component_config:Union[Literal[None], ComponentConfig]=None,
init_sd:Union[Literal[None], InitSD]=None, skip_main_serial=False,
- skip_stat_columns=None):
+ skip_stat_columns=None, stats_tier:str="full"):
+ if stats_tier not in STATS_TIERS:
+ raise ValueError(f"stats_tier must be one of {STATS_TIERS}, got {stats_tier!r}")
+ # Set before super().__init__ — assigning raw_df runs the whole cascade.
+ self.stats_tier = stats_tier
self.init_sd: InitSD
if init_sd is None:
self.init_sd = {}
@@ -491,21 +565,31 @@ def setup_options_from_analysis(self):
@observe('summary_sd', 'processed_result', 'clean_sd_key', 'filt_sd_key')
@exception_protect('merged_sd-protector')
def _merged_sd(self, change):
- # Bare keys come from the raw scope's SD (computed on
- # sampled_df). ``cleaned_*`` keys are layered on top from the
- # clean scope's SD when cleaning is active; ``filtered_*`` keys
- # are layered on top from the filt scope's SD when a search
- # filter is active. Scope SDs are read from the keyed cache
- # (#783) — the cache observer ``_populate_sd_cache`` is what
- # computes and stores them; this observer just assembles the
- # wire shape #777's `?key` JS consumes.
- #
# clean_sd_key and filt_sd_key are in the observed set so this
# fires after ``_populate_sd_cache`` has updated the pointers
# (which it always does, even on a pure cache hit) — guarantees
- # the cache lookups below see the right keys for the current
- # state.
-
+ # the cache lookups in ``_assemble_merged_sd`` see the right keys
+ # for the current state.
+ self.merged_sd = self._assemble_merged_sd()
+
+ def _assemble_merged_sd(self, running_sd: Optional[SDType] = None) -> SDType:
+ """The wire ``merged_sd`` for the current state.
+
+ Bare keys come from the raw scope's SD (computed on
+ sampled_df). ``cleaned_*`` keys are layered on top from the
+ clean scope's SD when cleaning is active; ``filtered_*`` keys
+ are layered on top from the filt scope's SD when a search
+ filter is active. Scope SDs are read from the keyed cache
+ (#783) — the cache observer ``_populate_sd_cache`` is what
+ computes and stores them; this only assembles the wire shape
+ #777's `?key` JS consumes.
+
+ ``running_sd`` is an SD still being computed for the filt scope's
+ frame (a ``StatRun``'s accumulated results). It stands in for every
+ scope whose chain is the filt chain, which are the scopes the cache
+ holds one entry for, so a caller can assemble the wire shape of
+ stats that are not in the cache yet.
+ """
# Resolve scope SDs. Falls back to summary_sd / cleaned_sd
# for pre-cache-population states (initial startup, the brief
# window before _populate_sd_cache has fired).
@@ -520,43 +604,17 @@ def _merged_sd(self, change):
if filt_sd is None:
filt_sd = self.summary_sd or {}
- # Gate each prefixed layer on a chain-shape diff between scopes.
- # ``cleaned_*`` fires when the clean chain has ops the raw chain
- # doesn't (cleaning is on); ``filtered_*`` fires when the filt
- # chain has ops the clean chain doesn't (at least one
- # quick-command op is present, i.e. a search filter is on).
- # Keying off ``filt_sd_key != raw_sd_key`` would also fire
- # ``filtered_*`` for cleaning-only states, mislabelling cleaned
- # stats as filtered.
chains = split_chain_by_scope(self.operations)
- cleaning_active = chains['clean'] != chains['raw']
- filter_active = chains['filt'] != chains['clean']
-
- if self.processed_df is None:
- #on initial startup
- self.merged_sd = merge_sds(self.init_sd, self.cleaned_sd, raw_sd, self.processed_sd)
- return
-
- #we do this to get rewrtten keys for init_sd
- rewritten_init_sd = merge_sd_overrides({}, self.processed_df, self.init_sd)
- intermediate_sd = merge_sds(rewritten_init_sd, self.cleaned_sd, raw_sd)
- base = merge_sd_overrides(intermediate_sd, self.processed_df, self.processed_sd)
-
- # Layer ``cleaned_*`` keys on top when cleaning is active.
- if cleaning_active and clean_sd:
- for col, stats in clean_sd.items():
- col_dict = base.setdefault(col, {})
- for stat_name, val in stats.items():
- col_dict[f'cleaned_{stat_name}'] = val
+ if running_sd is not None:
+ if chains['raw'] == chains['filt']:
+ raw_sd = running_sd
+ if chains['clean'] == chains['filt']:
+ clean_sd = running_sd
+ filt_sd = running_sd
- # Layer ``filtered_*`` keys on top when a filter is active.
- if filter_active and filt_sd:
- for col, stats in filt_sd.items():
- col_dict = base.setdefault(col, {})
- for stat_name, val in stats.items():
- col_dict[f'filtered_{stat_name}'] = val
-
- self.merged_sd = base
+ return assemble_merged_sd(
+ self.init_sd, self.cleaned_sd, raw_sd, self.processed_sd, self.processed_df,
+ chains, clean_sd=clean_sd, filt_sd=filt_sd)
def _compute_scope_df(self, scope: str):
"""Return the df that scope's SD should be computed against.
@@ -598,14 +656,14 @@ def _compute_scope_df(self, scope: str):
return base
return pp_result[0] if pp_result else base
- def _scope_cache_key(self, chain):
+ def _scope_cache_key(self, chain, tier=None):
"""Hash that identifies a scope's SD-input identity.
Includes the op chain *and* an identifier for the source
dataframe (``id(sampled_df)``) *and* the post-processing method
- *and* the analysis-klasses identity — all four are inputs to
- the scope's SD, and a cache hit must mean "same SD-producing
- inputs" not just "same chain".
+ *and* the analysis-klasses identity *and* the stats tier — all
+ five are inputs to the scope's SD, and a cache hit must mean
+ "same SD-producing inputs" not just "same chain".
- sampled_df identity addresses codex P1 on #783: a ``raw_df``
swap with an unchanged chain must invalidate.
@@ -617,11 +675,18 @@ def _scope_cache_key(self, chain):
- analysis_klasses identity addresses codex P2 on #783: a
klass-list swap with an unchanged chain must invalidate so
new stat klasses surface in ``merged_sd``.
+ - the tier keeps a schema-tier sd from being read as a full one:
+ without it, assigning full stats after a schema-tier
+ construction hits the schema entries and never reaches
+ ``merged_sd``. ``tier`` defaults to the dataflow's own, and is
+ explicit so a caller can address the full-tier entry while the
+ dataflow is still at the schema tier.
"""
sampled_id = id(self.sampled_df) if self.sampled_df is not None else 0
pp = self.post_processing_method or ''
klasses_id = id(self.analysis_klasses)
- return hash_chain(chain, extra=f"{sampled_id}|{pp}|{klasses_id}")
+ tier = tier or self.stats_tier
+ return hash_chain(chain, extra=f"{sampled_id}|{pp}|{klasses_id}|{tier}")
@observe('summary_sd', 'operations', 'analysis_klasses')
@exception_protect('sd-cache-protector')
@@ -653,9 +718,18 @@ def _populate_sd_cache(self, _change):
new_cache = dict(self.summary_stats_cache)
cache_grew = False
- # filt scope reuses the SD that _summary_sd just produced.
+ # filt scope reuses the SD that _summary_sd just produced, but only if
+ # it was produced for this frame, klass list and tier. An sd left over
+ # from another (a tier changed since it ran, or an assignment ahead of
+ # the cascade) would be stored under this key and never repaired, since
+ # a present key is a hit. Compute the scope's SD instead.
if keys['filt'] not in new_cache:
- new_cache[keys['filt']] = dict(self.summary_sd or {})
+ current = (id(self.processed_df), id(self.analysis_klasses), self.stats_tier)
+ if self._summary_sd_cache_key == current:
+ new_cache[keys['filt']] = dict(self.summary_sd or {})
+ else:
+ filt_sd, _errs = self._get_summary_sd(self.processed_df)
+ new_cache[keys['filt']] = filt_sd
cache_grew = True
# raw + clean: fresh compute, but only on cache miss.
@@ -708,14 +782,25 @@ def _build_error_dataframe(self, e) -> pd.DataFrame:
### start summary stats block
- #TAny closer to some error type
- @override
- def _get_summary_sd(self, processed_df: DataFrameT) -> Tuple[SDType, ErrDict]:
- stats = self.DFStatsClass(
+ def build_stats(self, processed_df: DataFrameT, run: bool = True) -> DfStats:
+ """The stats executor for ``processed_df``, built the one way every
+ summary-stats run builds it. With ``run=False`` it computes nothing yet
+ and the caller drives its ``plan`` and ``run``."""
+ # ``run`` is passed only when it is False, so a DFStatsClass written
+ # before it existed still builds on the default path.
+ extra = {} if run else {'run': False}
+ return self.DFStatsClass(
processed_df,
self.analysis_klasses,
self.df_name, debug=self.debug,
- skip_columns=getattr(self, 'skip_stat_columns', None))
+ skip_columns=getattr(self, 'skip_stat_columns', None), **extra)
+
+ #TAny closer to some error type
+ @override
+ def _get_summary_sd(self, processed_df: DataFrameT) -> Tuple[SDType, ErrDict]:
+ if self.stats_tier == "schema":
+ return self._get_schema_sd(processed_df), {}
+ stats = self.build_stats(processed_df)
sdf = stats.sdf
if stats.errs:
if self.debug:
@@ -728,6 +813,16 @@ def _get_summary_sd(self, processed_df: DataFrameT) -> Tuple[SDType, ErrDict]:
return sdf, {}
+ def _get_schema_sd(self, processed_df: DataFrameT) -> SDType:
+ """Identity and typing keys for every column, from the schema alone.
+
+ ``orig_col_name``, ``rewritten_col_name``, ``dtype``, the ``is_*``
+ flags, ``_type`` and ``length``: the keys column styling needs to pick
+ displayers, with no data query beyond the row count. A backend opts in
+ to ``stats_tier="schema"`` by overriding this.
+ """
+ raise NotImplementedError(f"{type(self).__name__} has no schema stats tier")
+
# ### end summary stats block
def _sd_to_jsondf(self, sd:SDType):
@@ -745,35 +840,43 @@ def _sd_to_jsondf(self, sd:SDType):
def _df_to_obj(self, df: DataFrameT) -> List[TDict[str, TAny]]:
return pd_to_obj(self.sampling_klass.serialize_sample(df))
+ def _analysis_klasses_with(self, analysis_klass:Type[ColAnalysis]) -> List[Type[ColAnalysis]]:
+ """The klass list the stats pipelines' ``add_stat`` builds: a class of the
+ same name is replaced, anything else is appended."""
+ klasses = list(self.analysis_klasses)
+ if isinstance(analysis_klass, type):
+ klasses = [k for k in klasses
+ if not (isinstance(k, type) and k.__name__ == analysis_klass.__name__)]
+ klasses.append(analysis_klass)
+ return klasses
+
def add_analysis(self, analysis_klass:Type[ColAnalysis]) -> None:
"""
same as get_summary_sd, call whatever to set summary_sd and trigger further comps
"""
- stats = self.DFStatsClass(
- self.processed_df,
- self.analysis_klasses,
- self.df_name, debug=self.debug)
- stats.add_analysis(analysis_klass)
-
- self.analysis_klasses = stats.ap.ordered_a_objs
+ if self.stats_tier == "full":
+ stats = self.DFStatsClass(
+ self.processed_df,
+ self.analysis_klasses,
+ self.df_name, debug=self.debug)
+ stats.add_analysis(analysis_klass)
+ self.analysis_klasses = stats.ap.ordered_a_objs
+ else:
+ # Building DFStatsClass here would run the stats this tier exists
+ # to skip (twice, since the constructor and add_analysis each
+ # process the frame), and it does not go through _get_summary_sd.
+ self.analysis_klasses = self._analysis_klasses_with(analysis_klass)
self.DFStatsClass.verify_analysis_objects(self.analysis_klasses)
self.setup_options_from_analysis()
#force recomputation
self._handle_widget_change({})
- #final processing block
- @observe('widget_args_tuple')
- def _handle_widget_change(self, change):
- """
- put together df_dict for consumption by the frontend
- """
- # Tuple[TAny, pd.DataFrame, SDType]
- _unused, processed_df, merged_sd = self.widget_args_tuple
- if processed_df is None:
- return
-
+ def _build_df_data_dict(self, processed_df, merged_sd):
+ """The ``df_data_dict`` trait value: the main rows, the ``all_stats``
+ payload and the empty placeholder. ``all_stats`` is the 5-11 ms part,
+ which is why it is built apart from ``_build_df_display_args``."""
# df_data_dict is still hardcoded for now
# eventually processed_df will be able to add or alter values of df_data_dict
# correlation would be added, filtered would probably be altered
@@ -781,15 +884,15 @@ def _handle_widget_change(self, change):
# to expedite processing maybe future provided dfs from
# postprcoessing could default to empty until that is
# selected, optionally
- if self.skip_main_serial:
- self.df_data_dict = {'main': [],
- 'all_stats': self._sd_to_jsondf(merged_sd),
- 'empty': []}
- else:
- self.df_data_dict = {'main': self._df_to_obj(processed_df),
- 'all_stats': self._sd_to_jsondf(merged_sd),
- 'empty': []}
-
+ main = [] if self.skip_main_serial else self._df_to_obj(processed_df)
+ return {'main': main,
+ 'all_stats': self._sd_to_jsondf(merged_sd),
+ 'empty': []}
+
+ def _build_df_display_args(self, processed_df, merged_sd):
+ """The ``df_display_args`` trait value: each display's column_config,
+ pinned_rows and keys, with the overrides applied. Reads ``merged_sd``
+ but serializes none of it."""
temp_display_args = {}
for display_name, A_Klass in self.df_display_klasses.items():
df_viewer_config = A_Klass.get_dfviewer_config(merged_sd, processed_df)
@@ -807,8 +910,21 @@ def _handle_widget_change(self, change):
temp_display_args['main']['df_viewer_config']['extra_grid_config'] = self.extra_grid_config
if self.component_config:
temp_display_args['main']['df_viewer_config']['component_config'] = self.component_config
+ return temp_display_args
+
+ #final processing block
+ @observe('widget_args_tuple')
+ def _handle_widget_change(self, change):
+ """
+ put together df_dict for consumption by the frontend
+ """
+ # Tuple[TAny, pd.DataFrame, SDType]
+ _unused, processed_df, merged_sd = self.widget_args_tuple
+ if processed_df is None:
+ return
- self.df_display_args = temp_display_args
+ self.df_data_dict = self._build_df_data_dict(processed_df, merged_sd)
+ self.df_display_args = self._build_df_display_args(processed_df, merged_sd)
"""
diff --git a/buckaroo/pluggable_analysis_framework/df_stats_v2.py b/buckaroo/pluggable_analysis_framework/df_stats_v2.py
index 4c9fcadf1..83036a5ef 100644
--- a/buckaroo/pluggable_analysis_framework/df_stats_v2.py
+++ b/buckaroo/pluggable_analysis_framework/df_stats_v2.py
@@ -20,15 +20,17 @@
from .col_analysis import AObjs, ColAnalysis
from .stat_pipeline import StatPipeline, errors_to_errdict
+from .stat_units import StatState, UnitStats
from .utils import FAST_SUMMARY_WHEN_GREATER
from .safe_summary_df import output_full_reproduce
-class DfStatsV2:
+class DfStatsV2(UnitStats):
"""Tie a StatPipeline to a DataFrame, exposing ``.sdf`` and ``.errs``.
Used by DataFlow, autocleaning, and other consumers as the pandas
- summary-stats executor.
+ summary-stats executor. ``run=False`` builds it without computing anything,
+ for a caller that drives ``plan`` / ``run`` itself.
"""
ap_class = StatPipeline
@@ -39,19 +41,23 @@ def verify_analysis_objects(cls, col_analysis_objs: AObjs) -> None:
cls.ap_class(col_analysis_objs)
def __init__(self, df_stats_df: pd.DataFrame, col_analysis_objs: AObjs, operating_df_name: str = None,
- debug: bool = False, skip_columns=None) -> None:
+ debug: bool = False, skip_columns=None, run: bool = True) -> None:
self.df = self.get_operating_df(df_stats_df, force_full_eval=False)
self.col_order = self.df.columns
self.ap = self.ap_class(col_analysis_objs)
self.operating_df_name = operating_df_name
self.debug = debug
+ self.state = StatState(self.df, frozenset(skip_columns or ()))
- self.sdf, errors = self.ap.process_df(self.df, self.debug, skip_columns=skip_columns)
- self.errs = errors_to_errdict(errors)
+ self.sdf = {}
+ self.errs = {}
self.stat_errors = []
+ if run:
+ self.sdf, errors = self.ap.process_df(self.df, self.debug, skip_columns=skip_columns)
+ self.errs = errors_to_errdict(errors)
- if self.errs:
- output_full_reproduce(self.errs, self.sdf, operating_df_name)
+ if self.errs:
+ output_full_reproduce(self.errs, self.sdf, operating_df_name)
def get_operating_df(self, df: pd.DataFrame, force_full_eval: bool) -> pd.DataFrame:
"""Downsample large DataFrames for performance."""
@@ -80,8 +86,9 @@ def add_analysis(self, a_obj: Type[ColAnalysis]) -> None:
self.ap.print_errors(errors + self.stat_errors)
-class PlDfStatsV2:
- """Polars summary-stats executor. Uses StatPipeline with @stat polars functions."""
+class PlDfStatsV2(UnitStats):
+ """Polars summary-stats executor. Uses StatPipeline with @stat polars functions.
+ ``run=False`` builds it without computing anything, as for ``DfStatsV2``."""
@classmethod
def verify_analysis_objects(cls, objs):
@@ -93,14 +100,18 @@ def get_operating_df(self, df):
return df.sample(n=min(50_000, rows), seed=42)
return df
- def __init__(self, df, col_analysis_objs, operating_df_name=None, debug=False, skip_columns=None):
+ def __init__(self, df, col_analysis_objs, operating_df_name=None, debug=False, skip_columns=None, run=True):
self.df = self.get_operating_df(df)
self.ap = StatPipeline(col_analysis_objs, unit_test=False)
- self.sdf, errors = self.ap.process_df(self.df, debug, skip_columns=skip_columns)
- self.errs = errors_to_errdict(errors)
+ self.state = StatState(self.df, frozenset(skip_columns or ()))
+ self.sdf = {}
+ self.errs = {}
self.stat_errors = []
- if self.errs:
- output_full_reproduce(self.errs, self.sdf, operating_df_name)
+ if run:
+ self.sdf, errors = self.ap.process_df(self.df, debug, skip_columns=skip_columns)
+ self.errs = errors_to_errdict(errors)
+ if self.errs:
+ output_full_reproduce(self.errs, self.sdf, operating_df_name)
def add_analysis(self, a_obj):
"""Add a new analysis class interactively."""
diff --git a/buckaroo/pluggable_analysis_framework/stat_pipeline.py b/buckaroo/pluggable_analysis_framework/stat_pipeline.py
index 67e8c0f77..812553ae4 100644
--- a/buckaroo/pluggable_analysis_framework/stat_pipeline.py
+++ b/buckaroo/pluggable_analysis_framework/stat_pipeline.py
@@ -9,16 +9,18 @@
from __future__ import annotations
import time
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
import pandas as pd
+from buckaroo.dataflow.df_types import DataFrameLike
from buckaroo.df_util import old_col_new_col
from . import perf_log
from .col_analysis import ColAnalysis, ErrDict, SDType
from .stat_func import (StatFunc, RawSeries, SampledSeries, RawDataFrame, XorqExpr, XorqExecute, RAW_MARKER_TYPES, MISSING, collect_stat_funcs)
from .stat_result import Ok, Err, UpstreamError, StatError, StatResult, resolve_accumulator
+from .stat_units import Fragment, StatAccumulator, StatState, StatUnit, UnitPipeline, columns_in_scope, prioritized
from .typed_dag import build_typed_dag, build_column_dag, DAGConfigError
from .utils import PERVERSE_DF
@@ -172,7 +174,7 @@ def _execute_stat_func(sf: StatFunc, accumulator: Dict[str, StatResult], column_
inputs=kwargs.copy())
-class StatPipeline:
+class StatPipeline(UnitPipeline):
"""Top-level orchestrator for the pluggable analysis framework.
Accepts a mix of:
@@ -276,9 +278,56 @@ def process_column(self, column_name: str, column_dtype, raw_series=None, sample
return resolve_accumulator(accumulator, column_name, col_key_to_func)
+ @staticmethod
+ def _skipped(state: StatState, pairs: Sequence[Tuple[Any, str]]) -> set:
+ """The columns of ``pairs`` named in ``state.skip_columns``, by original
+ or rewritten name."""
+ return {orig for orig, rewritten in pairs
+ if orig in state.skip_columns or rewritten in state.skip_columns}
+
+ def plan(self, state: StatState) -> List[StatUnit]:
+ """One unit per column that is not skipped, in column order (or the
+ order ``state.priority`` asks for). A column's stats read only its own
+ column, so no unit waits for another. Nothing is computed."""
+ if len(state.data) == 0:
+ return []
+ pairs = columns_in_scope(state)
+ skip = self._skipped(state, pairs)
+ active = prioritized(state, [pair for pair in pairs if pair[0] not in skip])
+ return [StatUnit(id=f"column:{orig}", columns=(orig,), phase="column") for orig, _rewritten in active]
+
+ def new_accumulator(self, state: StatState) -> StatAccumulator:
+ """The accumulator for a run of ``state``. A skipped column is entered
+ with its two names, as ``process_df`` always did, and a frame with no
+ rows has no entries at all."""
+ if self.record_timings:
+ self.timings = []
+ if len(state.data) == 0:
+ return StatAccumulator(state)
+ pairs = columns_in_scope(state)
+ acc = StatAccumulator(state, columns=[orig for orig, _rewritten in pairs], rewritten=dict(pairs))
+ for orig in self._skipped(state, pairs):
+ acc.results[orig] = {'orig_col_name': orig, 'rewritten_col_name': acc.rewritten[orig]}
+ return acc
+
+ def run(self, unit: StatUnit, acc: StatAccumulator) -> Fragment:
+ """Run one column's stats through the stat DAG and return them as
+ ``{orig_col: stats}``. The errors go on ``acc.errors``."""
+ df = acc.state.data
+ fragment: Fragment = {}
+ for orig_col_name in unit.columns:
+ rewritten_col_name = acc.rewritten[orig_col_name]
+ ser = df[orig_col_name]
+ col_result, col_errors = self.process_column(column_name=rewritten_col_name, column_dtype=ser.dtype,
+ raw_series=ser, sampled_series=ser, raw_dataframe=df,
+ initial_stats={'orig_col_name': orig_col_name, 'rewritten_col_name': rewritten_col_name})
+ fragment[orig_col_name] = col_result
+ acc.record({orig_col_name: col_result}, col_errors)
+ return fragment
+
def process_df(self, df: pd.DataFrame, debug: bool = False,
skip_columns=None) -> Tuple[SDType, List[StatError]]:
- """Process all columns of a DataFrame.
+ """Process all columns of a DataFrame: the planned units, run in order.
``skip_columns`` names columns whose summary stats are supplied
externally (e.g. via ``init_sd`` — reused from a source dataframe in a
@@ -292,35 +341,15 @@ def process_df(self, df: pd.DataFrame, debug: bool = False,
if len(df) == 0:
return {}, []
- if self.record_timings:
- self.timings = []
-
- skip = set(skip_columns or ())
- summary: SDType = {}
- all_errors: List[StatError] = []
-
- for orig_col_name, rewritten_col_name in old_col_new_col(df):
- if orig_col_name in skip or rewritten_col_name in skip:
- # Provided externally — keep the column, skip computation.
- summary[rewritten_col_name] = {
- 'orig_col_name': orig_col_name, 'rewritten_col_name': rewritten_col_name}
- continue
- ser = df[orig_col_name]
- col_dtype = ser.dtype
-
- col_result, col_errors = self.process_column(column_name=rewritten_col_name, column_dtype=col_dtype,
- raw_series=ser, sampled_series=ser, raw_dataframe=df,
- initial_stats={'orig_col_name': orig_col_name, 'rewritten_col_name': rewritten_col_name})
-
- summary[rewritten_col_name] = col_result
- all_errors.extend(col_errors)
+ acc = self.run_all(StatState(df, frozenset(skip_columns or ())))
+ summary: SDType = {acc.rewritten[orig]: stats for orig, stats in acc.sd().items()}
if self.record_timings and perf_log.enabled() and not self._suppress_perf_summary:
rec = perf_log.PerfRecorder(label=f"stats rows={len(df)} cols={len(summary)}")
rec.extend_timings("pandas/polars", self.timings)
rec.summary()
- return summary, all_errors
+ return summary, acc.errors
def unit_test(self) -> Tuple[bool, List[StatError]]:
"""Test the pipeline against PERVERSE_DF."""
@@ -432,3 +461,31 @@ def errors_to_errdict(errors: List[StatError]) -> ErrDict:
err_key = (se.column, se.stat_func.name if se.stat_func else "unknown")
errs[err_key] = (se.error, None)
return errs
+
+
+def schema_sd(df: DataFrameLike, column_typing: Callable[[Any], Dict[str, Any]],
+ skip_columns=None) -> SDType:
+ """The summary dict for ``df`` from its schema alone, with no stat run.
+
+ Shaped like ``StatPipeline.process_df``'s output: one entry per column,
+ keyed by the rewritten name, holding ``orig_col_name``,
+ ``rewritten_col_name``, ``length`` and whatever ``column_typing`` returns
+ for the column. ``column_typing`` is handed the column's series and may read
+ its dtype only; it is what keeps this from touching a value. As in
+ ``process_df``, a frame with no rows gives an empty dict and a column in
+ ``skip_columns`` gets only its two names, so its typing comes from
+ ``init_sd``.
+ """
+ length = len(df)
+ if length == 0:
+ return {}
+ skip = set(skip_columns or ())
+ summary: SDType = {}
+ for orig_col_name, rewritten_col_name in old_col_new_col(df):
+ col_stats: Dict[str, Any] = {
+ 'orig_col_name': orig_col_name, 'rewritten_col_name': rewritten_col_name}
+ if orig_col_name not in skip and rewritten_col_name not in skip:
+ col_stats.update(column_typing(df[orig_col_name]))
+ col_stats['length'] = length
+ summary[rewritten_col_name] = col_stats
+ return summary
diff --git a/buckaroo/pluggable_analysis_framework/stat_units.py b/buckaroo/pluggable_analysis_framework/stat_units.py
new file mode 100644
index 000000000..c5a02c1c4
--- /dev/null
+++ b/buckaroo/pluggable_analysis_framework/stat_units.py
@@ -0,0 +1,221 @@
+"""Resumable stat units (rows-first s4).
+
+A stat run is a list of units a caller can run one at a time. ``plan(state)``
+lists them without computing anything, ``new_accumulator(state)`` makes the
+object they share, and ``run(unit, acc)`` runs one and returns its *fragment*,
+a ``{orig_col: {stat: value}}`` dict holding the stats that unit produced. The
+pipelines' ``process_df`` and ``process_table`` are these three called in order,
+so running a whole table and running its units one by one cannot differ.
+
+A column named in ``StatState.skip_columns`` (its stats come from ``init_sd``)
+gets no unit. It still has an entry in ``acc.sd()``, with whatever structural
+keys the pipeline gives a skipped column, because ``process_df`` and
+``process_table`` always returned one.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple
+
+from buckaroo.df_util import old_col_new_col
+
+from .col_analysis import SDType
+from .stat_result import StatError
+
+# {orig_col: {stat: value}}: the stats one unit produced, for the columns it covers.
+Fragment = Dict[Any, Dict[str, Any]]
+
+
+@dataclass(frozen=True)
+class StatUnit:
+ """One bounded piece of a stat run.
+
+ ``id`` is unique within a plan. ``columns`` are original column names.
+ ``phase`` says what kind of work it is, and ``cost`` is a plain string
+ class (``column``, ``scan`` or ``query``) a scheduler can read. ``after``
+ names the units whose results this one reads, which must have run first.
+ """
+ id: str
+ columns: Tuple[Any, ...]
+ phase: str
+ after: Tuple[str, ...] = ()
+ cost: str = "column"
+
+
+# How a caller writes the column names it hands in (``StatState.columns`` and
+# ``priority``, and ``StatRun``'s ``prefer``): ``original`` names only,
+# ``rewritten`` (``a, b, c``) names only, or ``any``.
+NAMESPACES = ("any", "original", "rewritten")
+
+
+def check_namespace(namespace: str) -> None:
+ if namespace not in NAMESPACES:
+ raise ValueError(f"namespace must be one of {NAMESPACES}, not {namespace!r}")
+
+
+def resolve_names(pairs: Sequence[Tuple[Any, str]], names: Iterable[Any], namespace: str = "any") -> List[Any]:
+ """The original names of the columns that ``names`` pick out of ``pairs``,
+ the ``(orig, rewritten)`` of every column of the frame, in the order given.
+ A name that matches no column is ignored.
+
+ Each name is read once, so it picks one column. In ``any``, a name that is
+ an original column name picks that column, and only a name that is not one
+ is read as a rewritten name. That makes ``any`` right for a caller that
+ writes original names. A caller that holds the rewritten names, as a client
+ does, must say ``rewritten``: when one of its names is also another column's
+ original name (a frame with columns ``c, b, a`` has a column named ``c``
+ and a column rewritten to ``c``), ``any`` would read it as the original.
+ """
+ check_namespace(namespace)
+ originals = {orig for orig, _rewritten in pairs}
+ by_rewritten = {rewritten: orig for orig, rewritten in pairs}
+ picked: List[Any] = []
+ for name in names:
+ if namespace != "rewritten" and name in originals:
+ picked.append(name)
+ elif namespace != "original" and name in by_rewritten:
+ picked.append(by_rewritten[name])
+ return picked
+
+
+@dataclass(frozen=True, eq=False)
+class StatState:
+ """What a stat run describes: the frame or expression, and how to cut it.
+
+ ``skip_columns`` get no unit. ``columns`` restricts the run to a group (a
+ column-group request), and ``priority`` names columns to plan first. Both
+ are written in ``namespace`` (see ``resolve_names``): a client that only
+ knows the rewritten ``a, b, c`` names says ``rewritten``. ``rows`` is the
+ row count when the caller already has it, which the xorq column-chunk split
+ needs and which is never queried for.
+ """
+ data: Any
+ skip_columns: frozenset = frozenset()
+ columns: Optional[Tuple[Any, ...]] = None
+ priority: Tuple[Any, ...] = ()
+ rows: Optional[int] = None
+ namespace: str = "any"
+
+ def __post_init__(self) -> None:
+ check_namespace(self.namespace)
+
+
+def columns_in_scope(state: StatState) -> List[Tuple[Any, str]]:
+ """``(orig, rewritten)`` for each column the run covers, in data order. The
+ rewritten name is the column's position in the whole of ``state.data``,
+ whatever group is asked for."""
+ pairs = old_col_new_col(state.data)
+ if state.columns is None:
+ return list(pairs)
+ wanted = set(resolve_names(pairs, state.columns, state.namespace))
+ return [pair for pair in pairs if pair[0] in wanted]
+
+
+def prioritized(state: StatState, pairs: Sequence[Tuple[Any, str]]) -> List[Tuple[Any, str]]:
+ """``pairs`` with the columns named in ``state.priority`` first, in that
+ order, and the rest in their own order. The names are read against every
+ column of ``state.data``, not only ``pairs``, so a name picks the same
+ column whatever group is asked for."""
+ if not state.priority:
+ return list(pairs)
+ rank: Dict[Any, int] = {}
+ for position, orig in enumerate(resolve_names(old_col_new_col(state.data), state.priority, state.namespace)):
+ rank.setdefault(orig, position)
+ return sorted(pairs, key=lambda pair: rank.get(pair[0], len(rank)))
+
+
+class StatAccumulator:
+ """What the units of one run share: the state, the stats recorded so far
+ for each column (``results``, keyed by original name) and the errors the
+ units reported.
+
+ ``record`` is how a unit's fragment lands here. ``sd()`` is the result so
+ far in data order. A column no unit has run for is absent from it, unless
+ it is a skipped column, whose entry is there from the start.
+ """
+
+ def __init__(self, state: StatState, columns: Sequence[Any] = (), rewritten: Optional[Dict[Any, str]] = None):
+ self.state = state
+ self.order: List[Any] = list(columns)
+ self.rewritten: Dict[Any, str] = dict(rewritten or {})
+ self.results: Dict[Any, Dict[str, Any]] = {}
+ self.errors: List[StatError] = []
+
+ def record(self, fragment: Fragment, errors: Iterable[StatError] = ()) -> None:
+ for col, stats in fragment.items():
+ self.results.setdefault(col, {}).update(stats)
+ self.errors.extend(errors)
+
+ def sd(self) -> Dict[Any, Dict[str, Any]]:
+ return {col: self.results[col] for col in self.order if col in self.results}
+
+
+def merge_fragments(fragments: Iterable[Fragment]) -> Fragment:
+ """The union of fragments: each column's stats from every fragment that
+ has it. Neither the fragments nor their column dicts are changed."""
+ merged: Fragment = {}
+ for fragment in fragments:
+ for col, stats in fragment.items():
+ merged.setdefault(col, {}).update(stats)
+ return merged
+
+
+def rewrite_sd(results: Dict[Any, Dict[str, Any]], data: Any) -> SDType:
+ """``results`` (keyed by original column name) as the summary dict the
+ dataflow carries: keyed by the rewritten name, with ``orig_col_name`` and
+ ``rewritten_col_name`` set from ``data``'s columns. Empty when ``results``
+ is, as ``process_df`` is for an empty frame."""
+ if not results:
+ return {}
+ rewritten: SDType = {}
+ for orig_col, rewritten_col in old_col_new_col(data):
+ col_meta = dict(results.get(orig_col, {}))
+ col_meta['orig_col_name'] = orig_col
+ col_meta['rewritten_col_name'] = rewritten_col
+ rewritten[rewritten_col] = col_meta
+ return rewritten
+
+
+class UnitPipeline:
+ """The loop ``StatPipeline`` and ``XorqStatPipeline`` share: plan, then run
+ each unit. A subclass implements ``plan``, ``new_accumulator`` and ``run``."""
+
+ def plan(self, state: StatState) -> List[StatUnit]:
+ raise NotImplementedError
+
+ def new_accumulator(self, state: StatState) -> StatAccumulator:
+ raise NotImplementedError
+
+ def run(self, unit: StatUnit, acc: StatAccumulator) -> Fragment:
+ raise NotImplementedError
+
+ def iter_units(self, state: StatState, acc: StatAccumulator) -> Iterator[Tuple[StatUnit, Fragment]]:
+ """Plan the run and run its units in order, yielding each with its
+ fragment as it finishes. Nothing runs until the generator is advanced,
+ so a caller can stop between units."""
+ for unit in self.plan(state):
+ yield unit, self.run(unit, acc)
+
+ def run_all(self, state: StatState) -> StatAccumulator:
+ """Every unit, in order. What ``process_df`` and ``process_table`` are."""
+ acc = self.new_accumulator(state)
+ for _unit, _fragment in self.iter_units(state, acc):
+ pass
+ return acc
+
+
+class UnitStats:
+ """``plan``, ``new_accumulator`` and ``run`` for a stats class (``DfStatsV2``
+ and its siblings) that holds a pipeline in ``ap`` and the state it analyzes
+ in ``state``. The state defaults to the stats class's own."""
+ ap: Any
+ state: StatState
+
+ def plan(self, state: Optional[StatState] = None) -> List[StatUnit]:
+ return self.ap.plan(self.state if state is None else state)
+
+ def new_accumulator(self, state: Optional[StatState] = None) -> StatAccumulator:
+ return self.ap.new_accumulator(self.state if state is None else state)
+
+ def run(self, unit: StatUnit, acc: StatAccumulator) -> Fragment:
+ return self.ap.run(unit, acc)
diff --git a/buckaroo/pluggable_analysis_framework/xorq_stat_pipeline.py b/buckaroo/pluggable_analysis_framework/xorq_stat_pipeline.py
index 37eff339f..9601e2e03 100644
--- a/buckaroo/pluggable_analysis_framework/xorq_stat_pipeline.py
+++ b/buckaroo/pluggable_analysis_framework/xorq_stat_pipeline.py
@@ -1,14 +1,17 @@
"""Xorq-backed stat pipeline for the v2 framework.
-Two-phase execution:
+Two-phase execution, each phase a kind of resumable unit (see ``stat_units``):
1. Batch aggregate — every @stat with an XorqColumn parameter contributes
one ibis scalar expression. All such expressions across all columns
are folded into a single ``table.aggregate(...)`` query and executed
- once.
- 2. Per-column post-batch — computed stats (deps only on other stats) and
- XorqExpr-param stats (e.g. histograms that need their own query)
- run through the standard typed-DAG executor with results written into
- the per-column accumulator.
+ once. This is the ``batch`` unit, and it also computes the stats that
+ are pure functions of its results (``histogram_bins``, ``nan_per``...).
+ Behind a host's opt-in the batch can be cut into one aggregate per chunk
+ of columns (``chunk_cells``), for a plain parquet scan only.
+ 2. Per-column post-batch — XorqExpr-param stats (e.g. histograms that need
+ their own query), and any stat reading one, run through the standard
+ typed-DAG executor with results written into the per-column
+ accumulator. One ``histogram:
`` unit per column.
Errors are captured into ``StatError`` via the standard Ok/Err mechanism;
nothing is silently swallowed. Construction validates the DAG up front and
@@ -23,7 +26,7 @@
import os
import time
from contextlib import nullcontext
-from typing import Any, Dict, List, Tuple
+from typing import Any, Dict, List, Optional, Sequence, Tuple
import pandas as pd
@@ -33,6 +36,8 @@
from .stat_func import XorqColumn, XorqExpr, XorqExecute, RAW_MARKER_TYPES, StatFunc
from .stat_pipeline import _execute_stat_func, _normalize_inputs, errors_to_errdict
from .stat_result import Err, Ok, StatError, StatResult, resolve_accumulator
+from .stat_units import (Fragment, StatAccumulator, StatState, StatUnit, UnitPipeline, UnitStats, columns_in_scope,
+ prioritized)
from .typed_dag import build_column_dag, build_typed_dag
from .utils import PERVERSE_DF
@@ -41,10 +46,12 @@
try:
import xorq.api as xo
+ from xorq.expr.relations import Read
+ from xorq.vendor.ibis.expr import operations as ops
HAS_XORQ = True
except ImportError:
- xo = None
+ xo = Read = ops = None
HAS_XORQ = False
log = logging.getLogger(__name__)
@@ -100,7 +107,29 @@ def _is_batch_func(sf: StatFunc) -> bool:
return True
-class XorqStatPipeline:
+class XorqAccumulator(StatAccumulator):
+ """``StatAccumulator`` for a xorq run. ``working`` holds each column's
+ Ok/Err accumulator, which the units read and write; ``emitted`` the keys of
+ it already returned in a fragment, so each stat is returned and any error in
+ it reported once; ``phases`` caches how each dtype's stats split (see
+ ``XorqStatPipeline._column_phases``). ``sd()`` is every column's stats so
+ far in accumulator order, a failed stat as None."""
+
+ def __init__(self, state: StatState, columns: Sequence[Any] = (), rewritten: Optional[Dict[Any, str]] = None,
+ skipped: Sequence[Any] = ()):
+ super().__init__(state, columns, rewritten)
+ self.skipped: Tuple[Any, ...] = tuple(skipped)
+ self.working: Dict[Any, Dict[str, StatResult]] = {}
+ self.emitted: Dict[Any, set] = {}
+ self.phases: Dict[Any, Any] = {}
+ self.schema: Any = None
+
+ def sd(self) -> Dict[Any, Dict[str, Any]]:
+ return {col: {key: (result.value if isinstance(result, Ok) else None if isinstance(result, Err) else result)
+ for key, result in self.working[col].items()} for col in self.order}
+
+
+class XorqStatPipeline(UnitPipeline):
"""v2 stat pipeline for ``ibis.Table`` inputs.
Accepts the same kinds of inputs as ``StatPipeline``:
@@ -110,7 +139,13 @@ class XorqStatPipeline:
- ``ColAnalysis`` subclasses (via v1 adapter)
Use ``process_table(table)`` to run the pipeline; returns
- ``(SDType, List[StatError])``.
+ ``(SDType, List[StatError])``. ``plan(state)``, ``new_accumulator(state)``
+ and ``run(unit, acc)`` are the same run one unit at a time.
+
+ ``chunk_cells`` (off when ``None``) cuts the batch aggregate into one
+ aggregate per chunk of columns holding about that many cells, when the
+ caller gives the row count and the source is a plain parquet scan
+ (``chunk_refusal``).
"""
# Keys that the pipeline pre-populates per column. Listed as external
@@ -123,7 +158,7 @@ class XorqStatPipeline:
"distinct_count"})
def __init__(self, stat_funcs: list, backend: Any = None, unit_test: bool = True,
- cache_storage=None):
+ cache_storage=None, chunk_cells: Optional[int] = None):
if not HAS_XORQ:
raise ImportError(
"xorq is required for XorqStatPipeline. "
@@ -138,12 +173,13 @@ def __init__(self, stat_funcs: list, backend: Any = None, unit_test: bool = True
self._original_inputs = list(stat_funcs)
self.backend = backend
self.cache_storage = cache_storage
+ self.chunk_cells = chunk_cells
- # Per-run snapshot-cache counters, (re)initialised in process_table.
+ # Per-run snapshot-cache counters, (re)initialised in new_accumulator.
# Set here so the attribute always exists (e.g. for the unit_test()
# run kicked off below, which disables the cache).
self._cache_stats = _new_cache_run_stats()
- # Per-run perf recorder, (re)initialised in process_table when the
+ # Per-run perf recorder, (re)initialised in new_accumulator when the
# BUCKAROO_PERF toggle is on; None otherwise.
self._perf = None
# Set during the unit_test() DAG self-check so its PERVERSE_DF run
@@ -324,19 +360,21 @@ def _span(self, label, **fields):
return nullcontext()
return perf_log.perf_span(label, **fields)
- def process_table(self, table, skip_columns=None) -> Tuple[SDType, List[StatError]]:
+ def process_table(self, table, skip_columns=None, rows=None) -> Tuple[SDType, List[StatError]]:
# Each per-column query runs directly against ``table`` (the source
# expression). When a snapshot cache is set, queries are keyed against
# that source so the content-addressed key is stable across processes;
# ``_execute_cached`` serves a hit from the snapshot parquet or executes
- # and writes one on a miss.
- self._cache_stats = _new_cache_run_stats()
- self._perf = (perf_log.PerfRecorder()
- if perf_log.enabled() and not self._suppress_perf_summary else None)
+ # and writes one on a miss. ``rows`` is the row count when the caller
+ # has it; only the column-chunk split reads it.
+ state = StatState(table, frozenset(skip_columns or ()), rows=rows)
+ acc = self.new_accumulator(state)
_t0 = time.perf_counter()
with self._span("stat.xorq.total") as span:
try:
- return self._process_table_impl(table, skip_columns=skip_columns)
+ for _unit, _fragment in self.iter_units(state, acc):
+ pass
+ return acc.sd(), acc.errors
finally:
self._cache_stats["secs"] = round(time.perf_counter() - _t0, 4)
self._log_cache_stats(span)
@@ -347,27 +385,159 @@ def process_table(self, table, skip_columns=None) -> Tuple[SDType, List[StatErro
f"misses={self._cache_stats['misses']}")
self._perf.summary()
- def _process_table_impl(self, table, skip_columns=None) -> Tuple[SDType, List[StatError]]:
+ @staticmethod
+ def chunk_refusal(table) -> Optional[str]:
+ """Why ``table`` may not have its batch split by column chunk, or
+ ``None`` when it may.
+
+ Each chunk is its own aggregate over the source. On a parquet scan a
+ chunk reads only its columns, so the chunks cost about what one batch
+ does (0.90-1.16x at 10-12M rows) and each needs less memory. On
+ anything else a chunk runs the plan again: a join or an aggregate
+ executes in full per chunk, and a CSV read reparses the file (2.1-2.2x).
+ So the split is allowed for a parquet ``Read``, or a projection of
+ plain columns of one, and refused for everything else, including an
+ expression this does not recognise. A refusal can only keep the single
+ batch; it never turns the split on.
+ """
+ op = table.op()
+ if isinstance(op, ops.Project):
+ if not all(isinstance(value, ops.Field) for value in op.values.values()):
+ return "it computes columns instead of reading them"
+ op = op.parent
+ if not isinstance(op, Read):
+ return f"not a parquet scan ({type(op).__name__})"
+ if op.method_name != "read_parquet":
+ return f"not a parquet scan ({op.method_name})"
+ return None
+
+ def _batch_chunks(self, state: StatState, columns: List[Any]) -> List[List[Any]]:
+ """``columns`` cut into the chunks the batch runs in: one, unless the
+ split is on, the row count is known and the source allows it."""
+ if not self.chunk_cells:
+ return [columns]
+ if state.rows is None:
+ log.debug("xorq stat column chunking needs the row count; running one batch")
+ return [columns]
+ refusal = self.chunk_refusal(state.data)
+ if refusal is not None:
+ log.info("xorq stat column chunking refused: %s", refusal)
+ return [columns]
+ per_chunk = max(1, self.chunk_cells // max(state.rows, 1))
+ return [columns[i:i + per_chunk] for i in range(0, len(columns), per_chunk)] or [columns]
+
+ def _column_phases(self, cache: Dict[Any, Any], dtype) -> Tuple[List[StatFunc], List[StatFunc], Dict[str, StatFunc]]:
+ """A column's stat funcs split into those that need no further query
+ once the batch has run (``pure``) and those that run one or read the
+ result of one that does (``query``), each in dependency order, plus the
+ stat key -> func map errors are reported against."""
+ if dtype not in cache:
+ col_funcs = build_column_dag(self.all_stat_funcs, dtype, external_keys=self.EXTERNAL_KEYS)
+ pure: List[StatFunc] = []
+ query: List[StatFunc] = []
+ query_keys: set = set()
+ for sf in col_funcs:
+ if any(r.type in (XorqExpr, XorqExecute) or r.name in query_keys for r in sf.requires):
+ query.append(sf)
+ query_keys.update(sk.name for sk in sf.provides)
+ else:
+ pure.append(sf)
+ key_to_func = {sk.name: sf for sf in col_funcs for sk in sf.provides}
+ cache[dtype] = (pure, query, key_to_func)
+ return cache[dtype]
+
+ def plan(self, state: StatState) -> List[StatUnit]:
+ """The scalar batch first, then one histogram GROUP BY per column that
+ has one, columns named in ``state.priority`` first. The batch is cut
+ into chunks only as ``_batch_chunks`` allows, and every chunk precedes
+ every histogram, since a histogram reads what its chunk computes.
+ Skipped columns get no unit. Nothing is computed: no query is sent."""
+ table = state.data
+ pairs = columns_in_scope(state)
+ if not pairs:
+ return []
schema = table.schema()
- columns = list(table.columns)
- # Columns whose stats are supplied externally (via init_sd) keep their
- # structural metadata (name/dtype/length) but get no stat expressions
- # built — so the column's data is never scanned.
- skip = set(skip_columns or ())
-
- # Pre-populate every column accumulator with the externally-provided
- # keys. ``length`` is filled in by the batch query below. ``min`` /
- # ``max`` start as None so dependents (histogram) don't cascade-
- # exclude on non-numeric columns; ``min`` / ``max`` overwrite for
- # numeric cols. ``distinct_count`` likewise starts as None so float
- # columns (where the stat is column_filtered out) keep their
- # dependents (histogram, histogram_bins, distinct_per) runnable.
- accumulators: Dict[str, Dict[str, StatResult]] = {}
- for col in columns:
- accumulators[col] = {"orig_col_name": Ok(col), "rewritten_col_name": Ok(col), "dtype": Ok(str(schema[col])),
+ # xorq has always matched skip_columns on the column's own name only.
+ skip = {orig for orig, _rewritten in pairs if orig in state.skip_columns}
+ active = [orig for orig, _rewritten in prioritized(state, [p for p in pairs if p[0] not in skip])]
+ chunks = self._batch_chunks(state, active)
+ units: List[StatUnit] = []
+ batch_of: Dict[Any, str] = {}
+ for i, chunk in enumerate(chunks):
+ unit_id = "batch" if len(chunks) == 1 else f"batch:{i}"
+ units.append(StatUnit(id=unit_id, columns=tuple(chunk), phase="batch", cost="scan"))
+ batch_of.update({col: unit_id for col in chunk})
+ phases: Dict[Any, Any] = {}
+ for col in active:
+ if self._column_phases(phases, schema[col])[1]:
+ units.append(StatUnit(id=f"histogram:{col}", columns=(col,), phase="histogram",
+ after=(batch_of[col],), cost="query"))
+ return units
+
+ def new_accumulator(self, state: StatState) -> "XorqAccumulator":
+ """The accumulator for a run of ``state``, and the start of the run's
+ counters: the snapshot-cache hit/miss counts and the perf recorder.
+
+ Pre-populate every column accumulator with the externally-provided
+ keys. ``length`` is filled in by the batch query. ``min`` / ``max``
+ start as None so dependents (histogram) don't cascade-exclude on
+ non-numeric columns; ``min`` / ``max`` overwrite for numeric cols.
+ ``distinct_count`` likewise starts as None so float columns (where the
+ stat is column_filtered out) keep their dependents (histogram,
+ histogram_bins, distinct_per) runnable. A skipped column keeps these
+ (its stats are supplied via init_sd and its data is never scanned)."""
+ self._cache_stats = _new_cache_run_stats()
+ self._perf = (perf_log.PerfRecorder()
+ if perf_log.enabled() and not self._suppress_perf_summary else None)
+ schema = state.data.schema()
+ pairs = columns_in_scope(state)
+ acc = XorqAccumulator(state, columns=[orig for orig, _rewritten in pairs], rewritten=dict(pairs),
+ skipped=[orig for orig, _rewritten in pairs if orig in state.skip_columns])
+ for col in acc.order:
+ acc.working[col] = {"orig_col_name": Ok(col), "rewritten_col_name": Ok(col), "dtype": Ok(str(schema[col])),
"length": Ok(0), "min": Ok(None), "max": Ok(None), "distinct_count": Ok(None)}
-
- # ---- Phase 1: batch aggregate ----------------------------------
+ acc.emitted[col] = set()
+ acc.schema = schema
+ return acc
+
+ def run(self, unit: StatUnit, acc: StatAccumulator) -> Fragment:
+ """Run one unit and return the stats it produced, ``{col: {stat: value}}``
+ for its columns. A stat that fails is recorded on ``acc.errors`` once
+ and the run goes on."""
+ assert isinstance(acc, XorqAccumulator)
+ if unit.phase == "batch":
+ return self._run_batch(unit, acc)
+ return self._run_histogram(unit, acc)
+
+ def _run_funcs(self, funcs: List[StatFunc], col: Any, acc: "XorqAccumulator") -> None:
+ """Run ``funcs`` (in dependency order) for one column against its
+ accumulator, skipping stats whose results are already there (typically
+ the batch-phase stats)."""
+ col_accum = acc.working[col]
+ for sf in funcs:
+ if sf.provides and all(sk.name in col_accum for sk in sf.provides):
+ continue
+ if self._perf is not None:
+ t0 = time.perf_counter()
+ _execute_stat_func(sf, col_accum, col, raw_series=None, sampled_series=None, raw_dataframe=None,
+ xorq_expr=acc.state.data, xorq_execute=self._execute)
+ if self._perf is not None:
+ self._perf.record("xorq/per-column", col, sf.name, time.perf_counter() - t0)
+
+ def _emit(self, acc: "XorqAccumulator", col: Any, key_to_func: Dict[str, StatFunc]) -> Dict[str, Any]:
+ """The stats of ``col`` that no earlier unit has returned, as plain
+ values (a failed stat as None), recorded on ``acc`` with their errors."""
+ working = acc.working[col]
+ fresh = {key: result for key, result in working.items() if key not in acc.emitted[col]}
+ plain, errors = resolve_accumulator(fresh, col, key_to_func)
+ acc.emitted[col].update(fresh)
+ acc.record({col: plain}, errors)
+ return plain
+
+ def _run_batch(self, unit: StatUnit, acc: "XorqAccumulator") -> Fragment:
+ table = acc.state.data
+ schema = acc.schema
+ columns = list(unit.columns)
# ``length`` is a table-level scalar (same value for every column),
# so it goes in once as ``__total_length__`` rather than as N
# per-column expressions.
@@ -378,8 +548,6 @@ def _process_table_impl(self, table, skip_columns=None) -> Tuple[SDType, List[St
continue
xorq_col_param = next(r.name for r in sf.requires if r.type is XorqColumn)
for col in columns:
- if col in skip:
- continue
col_dtype = schema[col]
if sf.column_filter is not None and not sf.column_filter(col_dtype):
continue
@@ -387,7 +555,7 @@ def _process_table_impl(self, table, skip_columns=None) -> Tuple[SDType, List[St
expr = sf.func(**{xorq_col_param: table[col]})
except Exception as e:
for sk in sf.provides:
- accumulators[col][sk.name] = Err(error=e, stat_func_name=sf.name, column_name=col,
+ acc.working[col][sk.name] = Err(error=e, stat_func_name=sf.name, column_name=col,
inputs={"col": col})
continue
if expr is None:
@@ -397,7 +565,7 @@ def _process_table_impl(self, table, skip_columns=None) -> Tuple[SDType, List[St
expr = expr.name(f"{col}|{stat_name}")
except Exception as e:
for sk in sf.provides:
- accumulators[col][sk.name] = Err(error=e, stat_func_name=sf.name, column_name=col,
+ acc.working[col][sk.name] = Err(error=e, stat_func_name=sf.name, column_name=col,
inputs={"col": col})
continue
batch_items.append((col, sf, expr))
@@ -413,54 +581,40 @@ def _process_table_impl(self, table, skip_columns=None) -> Tuple[SDType, List[St
# length stays at the prepopulated 0 so consumers still see something.
for col, sf, _ in batch_items:
for sk in sf.provides:
- accumulators[col][sk.name] = Err(error=e, stat_func_name=sf.name, column_name=col, inputs={})
+ acc.working[col][sk.name] = Err(error=e, stat_func_name=sf.name, column_name=col, inputs={})
else:
total_length = _to_python_scalar(result_df[TOTAL_LENGTH_KEY].iloc[0])
if total_length is None:
total_length = 0
- for col in columns:
- accumulators[col]["length"] = Ok(total_length)
+ # A skipped column has no unit but has always been given the length.
+ for col in [*columns, *acc.skipped]:
+ acc.working[col]["length"] = Ok(total_length)
for col, sf, _ in batch_items:
stat_name = sf.provides[0].name
col_stat = f"{col}|{stat_name}"
if col_stat in result_df.columns:
raw_val = result_df[col_stat].iloc[0]
- accumulators[col][stat_name] = Ok(_to_python_scalar(raw_val))
+ acc.working[col][stat_name] = Ok(_to_python_scalar(raw_val))
else:
- accumulators[col][stat_name] = Err(error=KeyError(
+ acc.working[col][stat_name] = Err(error=KeyError(
f"missing aggregate column {col_stat!r} in result"), stat_func_name=sf.name, column_name=col, inputs={})
- # ---- Phase 2: per-column post-batch ----------------------------
- all_errors: List[StatError] = []
- summary: SDType = {}
-
+ # The stats that are pure functions of the ones just computed, so that
+ # histogram_bins (and with it color_map) needs no histogram query.
+ fragment: Fragment = {}
for col in columns:
- col_accum = accumulators[col]
- col_dtype = schema[col]
- col_funcs = build_column_dag(self.all_stat_funcs, col_dtype, external_keys=self.EXTERNAL_KEYS)
-
- for sf in col_funcs if col not in skip else []:
- # Skip stats whose results are already in the accumulator
- # (typically the batch-phase stats).
- if sf.provides and all(sk.name in col_accum for sk in sf.provides):
- continue
- if self._perf is not None:
- t0 = time.perf_counter()
- _execute_stat_func(sf, col_accum, col, raw_series=None, sampled_series=None, raw_dataframe=None,
- xorq_expr=table, xorq_execute=self._execute)
- if self._perf is not None:
- self._perf.record("xorq/per-column", col, sf.name, time.perf_counter() - t0)
-
- col_key_to_func: Dict[str, StatFunc] = {}
- for sf in col_funcs:
- for sk in sf.provides:
- col_key_to_func[sk.name] = sf
-
- plain, errors = resolve_accumulator(col_accum, col, col_key_to_func)
- summary[col] = plain
- all_errors.extend(errors)
-
- return summary, all_errors
+ pure, _query, key_to_func = self._column_phases(acc.phases, schema[col])
+ self._run_funcs(pure, col, acc)
+ fragment[col] = self._emit(acc, col, key_to_func)
+ return fragment
+
+ def _run_histogram(self, unit: StatUnit, acc: "XorqAccumulator") -> Fragment:
+ fragment: Fragment = {}
+ for col in unit.columns:
+ _pure, query, key_to_func = self._column_phases(acc.phases, acc.schema[col])
+ self._run_funcs(query, col, acc)
+ fragment[col] = self._emit(acc, col, key_to_func)
+ return fragment
def add_stat(self, stat_func_or_class) -> Tuple[bool, List[StatError]]:
"""Add a stat function or ColAnalysis class interactively.
@@ -501,7 +655,7 @@ def add_stat(self, stat_func_or_class) -> Tuple[bool, List[StatError]]:
return True, []
-class XorqDfStatsV2:
+class XorqDfStatsV2(UnitStats):
"""Stats wrapper for xorq table inputs.
Mirrors the ``DfStatsV2`` / ``PlDfStatsV2`` surface (``.sdf``, ``.errs``,
@@ -516,7 +670,9 @@ class XorqDfStatsV2:
Stats execute through ``XorqStatPipeline`` — a single batched
``table.aggregate(...)`` query plus per-column histogram queries —
pushing computation to the backend instead of materialising the
- entire table.
+ entire table. ``run=False`` builds the wrapper without running anything,
+ for a caller that drives ``plan`` / ``run`` itself. ``chunk_cells`` and
+ ``rows`` are the column-chunk split's size and the row count it needs.
"""
@classmethod
@@ -526,21 +682,26 @@ def verify_analysis_objects(cls, objs):
XorqStatPipeline(objs, unit_test=False)
def __init__(self, table, col_analysis_objs, operating_df_name=None, debug=False,
- cache_storage=None, skip_columns=None):
+ cache_storage=None, skip_columns=None, chunk_cells=None, rows=None, run=True):
self.table = table
# Skip the unit_test PERVERSE_DF run on each widget construction —
# it doubles the SQL query count (issue #709). The DAG-validation
# cost is already paid by verify_analysis_objects on first set up
# and by the test suite. Mirrors PlDfStatsV2.
self.ap = XorqStatPipeline(col_analysis_objs, unit_test=False,
- cache_storage=cache_storage)
+ cache_storage=cache_storage, chunk_cells=chunk_cells)
self.operating_df_name = operating_df_name
self.debug = debug
- self.sdf, errors = self.ap.process_table(self.table, skip_columns=skip_columns)
- self.errs = errors_to_errdict(errors)
+ self.rows = rows
+ self.state = StatState(table, frozenset(skip_columns or ()), rows=rows)
+ self.sdf: SDType = {}
+ self.errs = {}
self.stat_errors = []
- if self.errs:
- output_full_reproduce(self.errs, self.sdf, operating_df_name)
+ if run:
+ self.sdf, errors = self.ap.process_table(self.table, skip_columns=skip_columns, rows=rows)
+ self.errs = errors_to_errdict(errors)
+ if self.errs:
+ output_full_reproduce(self.errs, self.sdf, operating_df_name)
def cache_run_stats(self) -> dict:
"""The summary-stat cache outcome of the last pipeline run (#943) —
@@ -556,7 +717,7 @@ def add_analysis(self, a_obj):
so DataFlow.add_analysis works against a xorq-backed stats wrapper.
"""
passed, errors = self.ap.add_stat(a_obj)
- self.sdf, self.stat_errors = self.ap.process_table(self.table)
+ self.sdf, self.stat_errors = self.ap.process_table(self.table, rows=self.rows)
self.errs = errors_to_errdict(self.stat_errors)
if not passed:
print("DAG validation failed")
diff --git a/buckaroo/server/data_loading.py b/buckaroo/server/data_loading.py
index 1db3385cc..e162e6901 100644
--- a/buckaroo/server/data_loading.py
+++ b/buckaroo/server/data_loading.py
@@ -12,10 +12,11 @@
from buckaroo.dataflow.dataflow_extras import Sampling
from buckaroo.dataflow.autocleaning import PandasAutocleaning
from buckaroo.dataflow.styling_core import InitSD, StylingAnalysis
-from buckaroo.customizations.pd_stats_v2 import PD_ANALYSIS_V2
+from buckaroo.customizations.pd_stats_v2 import PD_ANALYSIS_V2, schema_stats
from buckaroo.customizations.styling import DefaultSummaryStatsStyling, DefaultMainStyling
from buckaroo.customizations.pd_autoclean_conf import CleaningConf, NoCleaningConf
from buckaroo.pluggable_analysis_framework.df_stats_v2 import DfStatsV2
+from buckaroo.pluggable_analysis_framework.stat_pipeline import schema_sd
class ServerSampling(Sampling):
@@ -49,6 +50,9 @@ def _df_to_obj(self, df):
# No sampling — matches BuckarooInfiniteWidget._df_to_obj
return pd_to_obj(df)
+ def _get_schema_sd(self, processed_df):
+ return schema_sd(processed_df, schema_stats, skip_columns=self.skip_stat_columns)
+
def create_dataflow(df: pd.DataFrame, column_config_overrides=None, extra_grid_config=None,
init_sd: InitSD | None = None) -> ServerDataflow:
diff --git a/buckaroo/server/data_loading_polars.py b/buckaroo/server/data_loading_polars.py
index 64e211782..adadee42b 100644
--- a/buckaroo/server/data_loading_polars.py
+++ b/buckaroo/server/data_loading_polars.py
@@ -27,7 +27,9 @@
from buckaroo.dataflow.styling_core import InitSD
from buckaroo.dataflow.autocleaning import PandasAutocleaning
from buckaroo.customizations.pl_autocleaning_conf import NoCleaningConfPl
+from buckaroo.customizations.pl_stats_v2 import pl_schema_stats
from buckaroo.pluggable_analysis_framework.df_stats_v2 import PlDfStatsV2
+from buckaroo.pluggable_analysis_framework.stat_pipeline import schema_sd
from buckaroo.polars_buckaroo import (
PLSampling, local_analysis_klasses, prepare_df_for_serialization)
from buckaroo.serialization_utils import pd_to_obj, make_infinite_resp
@@ -59,6 +61,9 @@ def _df_to_obj(self, df):
return pd_to_obj(self.sampling_klass.serialize_sample(df))
return pd_to_obj(self.sampling_klass.serialize_sample(df.to_pandas()))
+ def _get_schema_sd(self, processed_df):
+ return schema_sd(processed_df, pl_schema_stats, skip_columns=self.skip_stat_columns)
+
def load_file_polars(path: str) -> pl.DataFrame:
"""Eager polars read. Extension dispatch mirrors :func:`load_file`.
diff --git a/buckaroo/server/handlers.py b/buckaroo/server/handlers.py
index c60d99062..48100161a 100644
--- a/buckaroo/server/handlers.py
+++ b/buckaroo/server/handlers.py
@@ -14,7 +14,10 @@
from buckaroo.compare import col_join_dfs
from buckaroo.df_util import old_col_new_col
from buckaroo.server.focus import find_or_create_session_window
-from buckaroo.server.session import build_state_message
+from buckaroo.dataflow.dataflow import STATS_TIERS
+from buckaroo.server.session import (
+ STATS_DELIVERIES, begin_stats_generation, dataflow_stats_tier)
+from buckaroo.server.stats_wire import broadcast_state, refresh_session_snapshot
from buckaroo.server import telemetry
from buckaroo.pluggable_analysis_framework import perf_log
@@ -192,17 +195,10 @@ def _push_state_to_clients(self, session, metadata: dict):
if not session.ws_clients:
return
- for client in list(session.ws_clients):
- try:
- # Reset per-client live search first (dataset changed),
- # then build the msg so the injected ``buckaroo_state``
- # mirrors the post-reset value.
- client.search_string = ""
- msg = build_state_message(session, metadata=metadata,
- search_string=client.search_string)
- client.write_message(json.dumps(msg))
- except Exception:
- session.ws_clients.discard(client)
+ # Each client's search is reset first (dataset changed), then its
+ # message is built so the injected ``buckaroo_state`` mirrors the
+ # post-reset value.
+ broadcast_state(session, metadata=metadata, reset_search=True)
def _handle_browser_window(self, session_id: str) -> str:
"""Handle browser window management."""
@@ -371,6 +367,11 @@ async def post(self):
**component_config,
}
+ # /load builds no deferred stats, whatever policy a prior /load_expr on
+ # this session left behind, and the generation moves on with the data.
+ session.stats_tier, session.stats_delivery = "full", "inline"
+ begin_stats_generation(session)
+
# Notify connected clients and open browser
self._push_state_to_clients(session, metadata)
browser_action = "skipped" if no_browser else self._handle_browser_window(session_id)
@@ -380,6 +381,28 @@ async def post(self):
self.write({"session": session_id, "server_pid": os.getpid(), "browser_action": browser_action, **metadata})
+def _stats_policy_from_body(body: dict, current_tier: str, current_delivery: str):
+ """Read ``stats_tier`` and ``stats_delivery`` from a request body.
+
+ A field the body omits (or sends as null) keeps the current value (the
+ session's, or the default for a new session), as ``cache_dir`` does. Returns
+ ``(stats_tier, stats_delivery, None)``, or ``(None, None, error)`` with the
+ 400 response body for an unknown value."""
+ stats_tier = body.get("stats_tier")
+ if stats_tier is None:
+ stats_tier = current_tier
+ if stats_tier not in STATS_TIERS:
+ return None, None, {"error_code": "invalid_stats_tier",
+ "message": f"stats_tier must be one of {list(STATS_TIERS)}, got {stats_tier!r}"}
+ stats_delivery = body.get("stats_delivery")
+ if stats_delivery is None:
+ stats_delivery = current_delivery
+ if stats_delivery not in STATS_DELIVERIES:
+ return None, None, {"error_code": "invalid_stats_delivery",
+ "message": f"stats_delivery must be one of {list(STATS_DELIVERIES)}, got {stats_delivery!r}"}
+ return stats_tier, stats_delivery, None
+
+
class LoadExprHandler(tornado.web.RequestHandler):
"""POST /load_expr — load a xorq/ibis expression from a build dir
and serve it via the xorq-backed buckaroo dataflow.
@@ -438,7 +461,10 @@ async def post(self):
# Config-bearing fields that change how the result is computed or
# rendered. If the caller passes any of these on a warm POST we must
# re-run the pipeline — returning cached metadata would silently
- # ignore the new config.
+ # ignore the new config. stats_tier and stats_delivery are not in this
+ # tuple: it tests truthiness, and a host that sends the pair on every
+ # POST would never get the warm exit (#944). They are compared with the
+ # session's stored pair below instead.
has_config = any(body.get(k) for k in (
"component_config", "column_config_overrides", "extra_grid_config",
"init_sd", "skip_stat_columns"))
@@ -468,11 +494,22 @@ async def post(self):
# ~/.cache/xorq and recomputing the embedder's snapshots there.
if cache_dir is None and existing is not None:
cache_dir = existing.cache_dir
+ # The stats policy carries over the same way: a re-POST that omits it
+ # keeps the session's, and one that changes it rebuilds.
+ stats_tier, stats_delivery, policy_error = _stats_policy_from_body(
+ body,
+ existing.stats_tier if existing is not None else "full",
+ existing.stats_delivery if existing is not None else "inline")
+ if policy_error is not None:
+ self.set_status(400)
+ self.write(policy_error)
+ return
# /load swaps a session to pandas without clearing build_dir, so the
# backend is checked too — else its pandas metadata comes back here.
if (not force_reload and not has_config and existing
and existing.backend == "xorq" and existing.build_dir == build_dir
- and existing.cache_dir == cache_dir and existing.metadata):
+ and existing.cache_dir == cache_dir and existing.metadata
+ and (existing.stats_tier, existing.stats_delivery) == (stats_tier, stats_delivery)):
# The pipeline is skipped, but the refreshed page still opens a new
# WS and pulls a fresh time-to-first-rows. Re-arm first-pull telemetry
# on the existing session — rebind this request's sink and reset the
@@ -553,6 +590,7 @@ async def post(self):
with perf_log.perf_span("firstpull.dataflow_construct", session=session_id):
xorq_dataflow = xorq_loading.XorqServerDataflow(
expr, skip_main_serial=True, extra_klasses=extra_klasses,
+ stats_tier=dataflow_stats_tier(stats_tier, stats_delivery),
**dataflow_kwargs)
# Spanning metadata too leaves only the small klass-load step
# unmeasured inside the outer firstpull.load_expr total.
@@ -578,6 +616,8 @@ async def post(self):
session.cache_dir = cache_dir
session.project_root = project_root
session.dataflow_kwargs = dataflow_kwargs
+ session.stats_tier = stats_tier
+ session.stats_delivery = stats_delivery
session.tele_sink = tele_sink
session.xorq_dataflow = xorq_dataflow
# Clear pandas-side state left by a prior /load on the same
@@ -619,17 +659,13 @@ async def post(self):
**dvc.get("component_config", {}),
**component_config}
- if session.ws_clients:
- for client in list(session.ws_clients):
- try:
- # Reset per-client live search (#851): a term from
- # the prior expression would silently filter the new one.
- client.search_string = ""
- msg = build_state_message(session, metadata=metadata,
- search_string=client.search_string)
- client.write_message(json.dumps(msg))
- except Exception:
- session.ws_clients.discard(client)
+ # A new expression is a new stats generation; a deferred session starts
+ # it pending.
+ begin_stats_generation(session)
+
+ # Reset per-client live search (#851): a term from the prior
+ # expression would silently filter the new one.
+ broadcast_state(session, metadata=metadata, reset_search=True)
if no_browser or not self.application.settings.get("open_browser", False):
browser_action = "skipped"
@@ -766,18 +802,12 @@ async def post(self):
session.df_data_dict = display_state["df_data_dict"]
session.df_meta = display_state["df_meta"]
session.mode = "viewer"
+ # A viewer session has no dataflow and so no deferred stats.
+ session.stats_tier, session.stats_delivery = "full", "inline"
+ begin_stats_generation(session)
- # Push to WebSocket clients
- if session.ws_clients:
- for client in list(session.ws_clients):
- try:
- # Reset per-client live search (#851).
- client.search_string = ""
- msg = build_state_message(session,
- search_string=client.search_string)
- client.write_message(json.dumps(msg))
- except Exception:
- session.ws_clients.discard(client)
+ # Push to WebSocket clients. Reset per-client live search (#851).
+ broadcast_state(session, reset_search=True)
# Browser window
if no_browser or not self.application.settings.get("open_browser", False):
@@ -807,8 +837,22 @@ class ReloadExprHandler(tornado.web.RequestHandler):
``cache_storage_path`` store are cache hits, and column overrides,
extra grid config, init_sd and skip_stat_columns survive the reload.
+ The session's stored ``stats_tier`` / ``stats_delivery`` are replayed too.
+ The body is optional; a pair in it replaces the stored one (and is stored)
+ when the reload succeeds.
+
Returns 404 when the session does not exist, 400 when it is not a xorq
- session or has no project_root recorded, 501 when xorq is not installed."""
+ session, has no project_root recorded or carries an invalid stats policy,
+ 501 when xorq is not installed."""
+
+ def _optional_body(self) -> dict:
+ """The JSON object body, or ``{}`` when there is none or it is not an
+ object: the endpoint took no body before the stats policy fields."""
+ try:
+ body = json.loads(self.request.body)
+ except (json.JSONDecodeError, TypeError):
+ return {}
+ return body if isinstance(body, dict) else {}
async def post(self, session_id):
sessions = self.application.settings["sessions"]
@@ -831,6 +875,13 @@ async def post(self, session_id):
"message": "Session has no project_root — pass project_root to /load_expr first"})
return
+ stats_tier, stats_delivery, policy_error = _stats_policy_from_body(
+ self._optional_body(), session.stats_tier, session.stats_delivery)
+ if policy_error is not None:
+ self.set_status(400)
+ self.write(policy_error)
+ return
+
try:
from buckaroo.server import xorq_loading
except ImportError:
@@ -847,6 +898,7 @@ async def post(self, session_id):
+ xorq_loading.load_project_display_klasses(session.project_root))
xorq_dataflow = xorq_loading.XorqServerDataflow(
session.expr, skip_main_serial=True, extra_klasses=extra_klasses,
+ stats_tier=dataflow_stats_tier(stats_tier, stats_delivery),
**session.dataflow_kwargs)
except Exception:
tb = traceback.format_exc()
@@ -872,29 +924,13 @@ async def post(self, session_id):
if bs.get("quick_command_args"):
xorq_dataflow.quick_command_args = bs["quick_command_args"]
- refreshed = get_buckaroo_display_state(xorq_dataflow)
session.xorq_dataflow = xorq_dataflow
- session.df_display_args = refreshed["df_display_args"]
- session.df_data_dict = refreshed["df_data_dict"]
- session.df_meta = refreshed["df_meta"]
- session.buckaroo_options = refreshed["buckaroo_options"]
- session.command_config = refreshed["command_config"]
+ session.stats_tier = stats_tier
+ session.stats_delivery = stats_delivery
+ refresh_session_snapshot(session, xorq_dataflow)
+ begin_stats_generation(session)
- if session.component_config and session.df_display_args:
- for key in session.df_display_args:
- dvc = session.df_display_args[key].get("df_viewer_config")
- if dvc is not None:
- dvc["component_config"] = {
- **dvc.get("component_config", {}),
- **session.component_config}
-
- for client in list(session.ws_clients):
- try:
- msg = build_state_message(session,
- search_string=getattr(client, "search_string", ""))
- client.write_message(json.dumps(msg))
- except Exception:
- session.ws_clients.discard(client)
+ broadcast_state(session)
klass_count = len(extra_klasses)
log.info("reload_expr session=%s project_root=%s klasses=%d",
diff --git a/buckaroo/server/session.py b/buckaroo/server/session.py
index 563ce28fc..754ddc1ce 100644
--- a/buckaroo/server/session.py
+++ b/buckaroo/server/session.py
@@ -11,6 +11,36 @@
_DEFAULT_SESSION_TTL_S = 3600.0 # 1 hour idle before eviction
_DEFAULT_EVICTION_INTERVAL_S = 300.0 # check every 5 minutes
+# How a session reaches its stats tier: ``inline`` computes them in the
+# dataflow constructor (today's behaviour); ``deferred`` builds the schema tier
+# first and leaves the rest to later requests.
+STATS_DELIVERIES = ("inline", "deferred")
+
+
+def dataflow_stats_tier(stats_tier: str, stats_delivery: str) -> str:
+ """The tier a session's dataflow is constructed at. A deferred session
+ starts at the schema tier whatever tier it is headed for."""
+ return "schema" if stats_delivery == "deferred" else stats_tier
+
+
+# What ``df_meta.stats.status`` says about a session's stats for its current
+# ``stats_gen``: ``complete`` (the stats of the tier the session is headed for
+# are in the snapshot), ``pending`` (a deferred session that has not produced
+# them yet), ``not_computed`` (the session is headed for the schema tier, so
+# none will arrive) and ``error`` (the run failed; sticky until the next
+# generation).
+STATS_STATUSES = ("complete", "pending", "not_computed", "error")
+
+
+def initial_stats_status(stats_tier: str, stats_delivery: str) -> tuple[str, Optional[str]]:
+ """The status, and its reason, of a session whose stats generation has just
+ started, from the policy pair alone."""
+ if stats_tier != "full":
+ return "not_computed", "host"
+ if stats_delivery == "deferred":
+ return "pending", None
+ return "complete", None
+
@dataclass
class SessionState:
@@ -43,6 +73,28 @@ class SessionState:
# extra_grid_config, init_sd, skip_stat_columns), replayed by /reload_expr so
# a reload keeps stat caching and column config (#957).
dataflow_kwargs: dict = field(default_factory=dict)
+ # The /load_expr stats policy. ``stats_tier`` is the tier the session is
+ # headed for (see dataflow.STATS_TIERS) and ``stats_delivery`` how it gets
+ # there (see STATS_DELIVERIES). Kept apart from dataflow_kwargs, which is
+ # splatted into the dataflow constructor, and replayed by /reload_expr. The
+ # tier the dataflow is built at follows from the pair (dataflow_stats_tier).
+ stats_tier: str = "full"
+ stats_delivery: str = "inline"
+ # The stats generation: a counter the server owns, bumped whenever the
+ # dataflow state the stats describe changes (/load, /load_expr, /load_compare,
+ # /reload_expr, and a buckaroo_state_change that touches a dataflow field).
+ # It rides on df_meta.stats and on every stats message, so a client can drop
+ # a reply for a state it has left. Independent of any client-owned request
+ # sequence (state_seq). ``stats_status`` and ``stats_reason`` live here, not
+ # in ``df_meta``, because the dataflow rebuilds df_meta wholesale.
+ stats_gen: int = 0
+ stats_status: str = "complete"
+ stats_reason: Optional[str] = None
+ # The ``StatRun``s (stat_run.py) of the current generation, keyed by
+ # ``(stats_gen, scope)``: the planned units, the fragments they have
+ # produced and the accumulator they read. Dropped whenever the generation
+ # changes, since a run describes one state of the dataflow.
+ stat_runs: dict = field(default_factory=dict)
# Companion telemetry sink (#943): a fire-and-forget POST callable, built
# once from the /load_expr payload's telemetry_url on the IOLoop (where
# make_http_sink captures AsyncHTTPClient/IOLoop.current()). Stored here so
@@ -80,6 +132,35 @@ def touch(self) -> None:
this field is the runtime escape hatch."""
+def begin_stats_generation(session: "SessionState") -> None:
+ """Start a new stats generation: the session's dataflow state has changed,
+ so stats for the previous one no longer describe it. The status restarts
+ from the session's policy pair, and the stat runs of the old generation are
+ dropped."""
+ session.stats_gen += 1
+ session.stats_status, session.stats_reason = initial_stats_status(
+ session.stats_tier, session.stats_delivery)
+ session.stat_runs.clear()
+
+
+def stats_meta(session: "SessionState") -> Optional[dict]:
+ """The ``df_meta.stats`` value for a session: ``{status, tier, gen}`` plus
+ ``reason`` when there is one. ``tier`` is the tier reached so far, which is
+ the one the session is headed for only once it is complete.
+
+ ``None`` for a session on the default policy (inline delivery, full tier,
+ complete), which sends the message it always has; a client reads a missing
+ ``stats`` as complete."""
+ if session.stats_status == "complete" and session.stats_delivery != "deferred":
+ return None
+ stats: dict = {"status": session.stats_status,
+ "tier": session.stats_tier if session.stats_status == "complete" else "schema",
+ "gen": session.stats_gen}
+ if session.stats_reason:
+ stats["reason"] = session.stats_reason
+ return stats
+
+
def build_state_message(session: "SessionState", metadata: dict | None = None,
search_string: str = "") -> dict:
"""Build the full ``initial_state`` WebSocket payload from a session.
@@ -98,10 +179,16 @@ def build_state_message(session: "SessionState", metadata: dict | None = None,
Returns:
A dict ready to be JSON-serialised and sent to WebSocket clients.
"""
+ # The dataflow rebuilds df_meta wholesale, so the stats status is injected
+ # here, into a copy, rather than stored in it.
+ df_meta = session.df_meta
+ stats = stats_meta(session)
+ if stats is not None:
+ df_meta = {**df_meta, "stats": stats}
msg: dict = {"type": "initial_state", "protocol_version": PROTOCOL_VERSION,
"metadata": metadata if metadata is not None else session.metadata,
"prompt": session.prompt, "df_display_args": session.df_display_args, "df_data_dict": session.df_data_dict,
- "df_meta": session.df_meta, "mode": session.mode}
+ "df_meta": df_meta, "mode": session.mode}
if session.mode == "buckaroo":
# Per-client search_string overlay (#851 Codex P1): the snapshot
# on the session is search-agnostic; we re-inject the recipient's
diff --git a/buckaroo/server/stat_run.py b/buckaroo/server/stat_run.py
new file mode 100644
index 000000000..82a13fba7
--- /dev/null
+++ b/buckaroo/server/stat_run.py
@@ -0,0 +1,135 @@
+"""The stat run a session keeps for one stats generation (rows-first s4).
+
+A ``StatRun`` holds the planned units of one ``(stats_gen, scope)``, the
+fragments they produced in the order they finished, and the accumulator the
+units read. It runs a unit only when its caller asks: it has no thread, timer or
+callback, so there is nothing to cancel, and the session drops it when the
+generation changes (``begin_stats_generation``).
+
+The fragment list is append-only and shared. A ``StatCursor`` is one client's
+position in it, held by that client's WebSocket handler, so work is done once
+and every client reads all of it at its own pace.
+"""
+import itertools
+import time
+from typing import Any, List, Optional, Sequence, Tuple
+
+from buckaroo.df_util import old_col_new_col
+from buckaroo.pluggable_analysis_framework.stat_pipeline import errors_to_errdict
+from buckaroo.pluggable_analysis_framework.stat_units import Fragment, StatUnit, resolve_names, rewrite_sd
+
+_run_ids = itertools.count(1)
+
+
+class StatRun:
+ """``stats`` is a stats class (``DfStatsV2`` and its siblings) built with
+ ``run=False``: the run plans it once and runs its units through it.
+
+ ``status`` is ``pending`` until the last unit has run, then ``complete``;
+ ``error`` once a unit raised, and the run is not retried. A stat that fails
+ inside a unit is not that: it is an entry in ``acc.errors`` and the unit
+ still finishes. ``elapsed_s`` is the time the units have taken, summed over
+ every request that ran one.
+ """
+
+ def __init__(self, stats_gen: int, scope: str, stats: Any):
+ self.id = next(_run_ids)
+ self.stats_gen = stats_gen
+ self.scope = scope
+ self.stats = stats
+ self.units: List[StatUnit] = stats.plan(stats.state)
+ self.acc = stats.new_accumulator(stats.state)
+ self.fragments: List[Fragment] = []
+ self.ran: List[str] = []
+ self.status = "pending" if self.units else "complete"
+ self.error: Optional[Exception] = None
+ self.created_at = time.time()
+ self.elapsed_s = 0.0
+ self._frame_columns: Optional[List[Tuple[Any, str]]] = None
+
+ @property
+ def key(self) -> Tuple[int, str]:
+ return (self.stats_gen, self.scope)
+
+ @property
+ def remaining(self) -> int:
+ """The number of units not yet run."""
+ return len(self.units) - len(self.ran)
+
+ def next_unit(self, prefer: Sequence[Any] = (), namespace: str = "any") -> Optional[StatUnit]:
+ """The unit ``run_next`` would run: the first whose prerequisites have
+ run, or the first of those that covers a column named in ``prefer``, so
+ the columns a client is looking at come first. The names are written in
+ ``namespace`` (see ``resolve_names``): a client that holds the rewritten
+ ``a, b, c`` names says ``rewritten``. ``None`` when no unit is left."""
+ ran = set(self.ran)
+ ready = [unit for unit in self.units if unit.id not in ran and all(a in ran for a in unit.after)]
+ if prefer:
+ if self._frame_columns is None:
+ self._frame_columns = old_col_new_col(self.acc.state.data)
+ wanted = set(resolve_names(self._frame_columns, prefer, namespace))
+ for unit in ready:
+ if any(col in wanted for col in unit.columns):
+ return unit
+ return ready[0] if ready else None
+
+ def run_next(self, prefer: Sequence[Any] = (), namespace: str = "any") -> Optional[Fragment]:
+ """Run one unit and return its fragment, which is also appended to
+ ``fragments``. ``None`` when the run is complete or failed. A unit that
+ raises fails the run, and the exception propagates once. ``prefer`` and
+ ``namespace`` are as in ``next_unit``."""
+ if self.status != "pending":
+ return None
+ unit = self.next_unit(prefer, namespace)
+ if unit is None:
+ self.status = "complete"
+ return None
+ started = time.perf_counter()
+ try:
+ fragment = self.stats.run(unit, self.acc)
+ except Exception as exc:
+ self.status, self.error = "error", exc
+ raise
+ finally:
+ self.elapsed_s += time.perf_counter() - started
+ self.ran.append(unit.id)
+ self.fragments.append(fragment)
+ if self.remaining == 0:
+ self.status = "complete"
+ return fragment
+
+ def raw_sd(self) -> dict:
+ """The run's stats as the summary dict the dataflow carries, keyed by
+ rewritten column name: what ``summary_sd`` holds after a whole run."""
+ return rewrite_sd(self.acc.sd(), self.acc.state.data)
+
+ def errs(self) -> dict:
+ """The stats that failed inside a unit, as the ``{(col, stat): (error,
+ None)}`` dict the dataflow's ``errs`` holds."""
+ return errors_to_errdict(self.acc.errors)
+
+
+class StatCursor:
+ """One client's position in a ``StatRun``'s fragment list. It follows one
+ run at a time and starts again at the beginning of any other, since a
+ position in one run means nothing in another."""
+
+ def __init__(self) -> None:
+ self.run_id: Optional[int] = None
+ self.position = 0
+
+ def _follow(self, run: StatRun) -> None:
+ if self.run_id != run.id:
+ self.run_id, self.position = run.id, 0
+
+ def take(self, run: StatRun) -> List[Fragment]:
+ """The fragments of ``run`` this cursor has not returned yet, and move
+ past them."""
+ self._follow(run)
+ unseen = run.fragments[self.position:]
+ self.position += len(unseen)
+ return unseen
+
+ def caught_up(self, run: StatRun) -> bool:
+ """Whether the cursor has returned every fragment ``run`` has now."""
+ return (self.position if self.run_id == run.id else 0) >= len(run.fragments)
diff --git a/buckaroo/server/stats_wire.py b/buckaroo/server/stats_wire.py
new file mode 100644
index 000000000..950daf29a
--- /dev/null
+++ b/buckaroo/server/stats_wire.py
@@ -0,0 +1,496 @@
+"""Server side of the stats wire protocol (rows-first s3 and s5).
+
+A deferred session (``stats_delivery="deferred"``) publishes its dataflow at the
+schema tier and leaves the rest of the stats to later. Two kinds of client
+reach them:
+
+* A client that advertised ``?caps=stats_update`` on its WebSocket URL gets a
+ stats-free ``initial_state`` (``df_meta.stats.status == "pending"``), pulls
+ the stats with ``stats_request {stats_gen, scope}`` and merges the
+ ``stats_update`` that answers it. A request for a generation the session has
+ left gets ``stats_aborted``.
+* Any other client gets complete messages: ``build_state_message_for`` runs the
+ missing stats synchronously before it builds a message for one, which is
+ today's cost, paid on the loop.
+
+Every send site goes through ``build_state_message_for`` (or ``broadcast_state``,
+which calls it per client), because the session holds one shared snapshot and
+the client is known only to the handler that owns the connection.
+
+A ``stats_request`` takes one of two shapes. Without ``incremental`` it is the
+whole run: one synchronous call that runs every unit still to run, applies the
+final assignment and answers with the complete ``all_stats``. With
+``incremental: true`` it runs the units of the session's ``StatRun`` for about
+``STATS_BUDGET_S`` (at least one, so a cold unit is the only one of its
+request) and answers with the fragments this client has not seen. The request
+that runs the last unit applies the final assignment (``complete_stats``): the
+full-tier cache entry, ``summary_sd``, the session snapshot and the status,
+in one step. A client that follows the run reads the same list through its own
+cursor, so work is done once whoever asks.
+"""
+import copy
+import hashlib
+import json
+import logging
+import time
+import traceback
+from typing import Any, Callable, Dict, Optional, Sequence
+
+from buckaroo.dataflow.sd_cache import split_chain_by_scope
+from buckaroo.df_util import old_col_new_col
+from buckaroo.pluggable_analysis_framework import perf_log
+from buckaroo.pluggable_analysis_framework.stat_units import Fragment
+from buckaroo.server.data_loading import get_buckaroo_display_state
+from buckaroo.server.session import SessionState, build_state_message
+from buckaroo.server.stat_run import StatCursor, StatRun
+
+log = logging.getLogger("buckaroo.server.stats_wire")
+
+# The capability a client advertises, as one of the comma-separated values of
+# ``?caps=`` on the WebSocket URL: it merges ``stats_update`` messages. The
+# connection is the only place it can be recorded, because ``open()`` sends the
+# first message before the client has said anything.
+STATS_UPDATE_CAP = "stats_update"
+
+# The scopes a ``stats_request`` can name. ``raw`` is the unfiltered table of
+# the session's current generation; filtered stats exist only through a
+# dataflow-field change (``quick_command_args.search``), which bumps the
+# generation.
+STATS_SCOPES = ("raw",)
+
+# The time an incremental ``stats_request`` may spend running units, in
+# seconds. A request always runs one unit and stops starting new ones once this
+# is spent, so a snapshot-cache hit (milliseconds) shares its request with the
+# others and a query is alone in its own. It cannot cut a unit short: the
+# longest unit is what a waiting ``infinite_request`` stalls behind.
+STATS_BUDGET_S = 0.075
+
+
+def parse_caps(raw: str) -> frozenset:
+ """The capabilities in a ``?caps=a,b`` value."""
+ return frozenset(cap for cap in (part.strip() for part in raw.split(",")) if cap)
+
+
+def client_has_cap(client: Any, cap: str) -> bool:
+ """Whether a WebSocket handler recorded ``cap`` at open. Anything else with
+ no ``caps`` (a test double, a future non-WebSocket client) has none."""
+ return cap in getattr(client, "caps", ())
+
+
+def session_dataflow(session: Optional[SessionState]) -> Any:
+ """The dataflow behind a buckaroo-mode session, or ``None`` (viewer and lazy
+ sessions have none)."""
+ if session is None or session.mode != "buckaroo":
+ return None
+ return session.xorq_dataflow if session.backend == "xorq" else session.dataflow
+
+
+def refresh_session_snapshot(session: SessionState, dataflow: Any) -> None:
+ """Copy the dataflow's display state onto the session snapshot that new
+ clients and every push read, and re-apply ``component_config`` so theme
+ settings survive. The dataflow reaches no client until this runs."""
+ refreshed = get_buckaroo_display_state(dataflow)
+ session.df_display_args = refreshed["df_display_args"]
+ session.df_data_dict = refreshed["df_data_dict"]
+ session.df_meta = refreshed["df_meta"]
+ session.buckaroo_options = refreshed["buckaroo_options"]
+ session.command_config = refreshed["command_config"]
+ if session.component_config and session.df_display_args:
+ for key in session.df_display_args:
+ dvc = session.df_display_args[key].get("df_viewer_config")
+ if dvc is not None:
+ dvc["component_config"] = {**dvc.get("component_config", {}), **session.component_config}
+
+
+def start_stat_run(session: SessionState, scope: str = "raw") -> Optional[StatRun]:
+ """The ``StatRun`` of the session's current generation for ``scope``: the one
+ it holds, or a new one planned from the dataflow and stored on the session.
+ Planning sends no query. The run analyzes the frame ``assign_full_stats``
+ does, so running every unit gives the stats that call computes whole.
+ ``None`` when there is no dataflow to plan from or the scope is not one a
+ request can name."""
+ dataflow = session_dataflow(session)
+ if dataflow is None or dataflow.processed_df is None or scope not in STATS_SCOPES:
+ return None
+ key = (session.stats_gen, scope)
+ run = session.stat_runs.get(key)
+ if run is None:
+ run = StatRun(session.stats_gen, scope, dataflow.build_stats(dataflow.processed_df, run=False))
+ session.stat_runs[key] = run
+ return run
+
+
+def display_args_hash(display_args: Any) -> str:
+ """A digest of a ``df_display_args`` value, equal for equal content whatever
+ the key order. JSON, because that is how the value reaches a client, and it
+ reads a NaN the same each time."""
+ return hashlib.sha1(json.dumps(display_args, sort_keys=True, default=str).encode()).hexdigest()
+
+
+def highlighted_display_args(display_args: dict, term: str) -> dict:
+ """A copy of ``df_display_args`` with ``term`` set as the highlight phrase
+ of every string column's displayer, or with the highlight removed when the
+ term is empty (#851). A copy, so the shared session snapshot is never
+ changed."""
+ overlay = copy.deepcopy(display_args)
+ for dva in overlay.values():
+ dvc = (dva or {}).get("df_viewer_config") or {}
+ for col in dvc.get("column_config", []) or []:
+ disp = col.get("displayer_args")
+ if not isinstance(disp, dict) or disp.get("displayer") != "string":
+ continue
+ if term:
+ disp["highlight_phrase"] = [term]
+ else:
+ disp.pop("highlight_phrase", None)
+ return overlay
+
+
+def run_units(run: StatRun, budget_s: Optional[float], prefer: Sequence[str] = (),
+ clock: Callable[[], float] = time.perf_counter, session_id: Optional[str] = None) -> int:
+ """Run units of ``run`` for one request, and return how many.
+
+ At least one runs while the run is pending. Another starts only while the
+ time spent is under ``budget_s``, so units that cost milliseconds (their
+ queries are snapshot-cache hits) run together and one that is a query ends
+ its request. ``budget_s=None`` runs every unit still to run. ``prefer`` are
+ column names as a client writes them (the rewritten ``a, b, c``), whose
+ units go first. A unit that raises fails the run and the exception
+ propagates. Each unit is a ``stats.unit`` span."""
+ started = clock()
+ ran = 0
+ while run.status == "pending":
+ if ran and budget_s is not None and clock() - started >= budget_s:
+ break
+ unit = run.next_unit(prefer, "rewritten")
+ if unit is None:
+ run.run_next() # no unit is left: this marks the run complete
+ break
+ with perf_log.perf_span("stats.unit", session=session_id, stats_gen=run.stats_gen, unit=unit.id,
+ phase=unit.phase, cost=unit.cost, columns=len(unit.columns)):
+ run.run_next(prefer, "rewritten")
+ ran += 1
+ return ran
+
+
+def partial_payload(dataflow: Any, run: StatRun, fragments: Sequence[Fragment]) -> Any:
+ """The ``all_stats`` payload (an inline wide ``DFEnvelope``) of the columns
+ ``fragments`` cover: their stats so far, assembled as ``merged_sd`` is, so
+ ``init_sd``, a processing step's sd and the ``cleaned_*`` and ``filtered_*``
+ layers apply as they will in the complete state. A client merges it into
+ its ``all_stats`` key by key, so a stat sent again with a later fragment
+ only replaces itself."""
+ rewritten = dict(old_col_new_col(run.acc.state.data))
+ touched = {rewritten[col] for fragment in fragments for col in fragment if col in rewritten}
+ merged = dataflow._assemble_merged_sd(run.raw_sd())
+ return dataflow._sd_to_jsondf({col: stats for col, stats in merged.items() if col in touched})
+
+
+def _full_stats_key(dataflow: Any) -> Any:
+ """The ``summary_stats_cache`` key of the current state's full-tier sd."""
+ return dataflow._scope_cache_key(split_chain_by_scope(dataflow.operations)["filt"], tier="full")
+
+
+def _cached_full_sd(dataflow: Any) -> Any:
+ """The full-tier sd of the current state if an earlier visit to it left one."""
+ return dataflow.summary_stats_cache.get(_full_stats_key(dataflow))
+
+
+def assign_full_stats(dataflow: Any, computed: Optional[tuple] = None) -> None:
+ """Take a schema-tier dataflow to the full tier: use the current state's
+ full stats (found in ``summary_stats_cache``, where an earlier visit to the
+ same state left them, or ``computed``, the ``(sd, errs)`` of a finished
+ ``StatRun``, or computed here whole), write them under the full-tier key,
+ then assign ``summary_sd``. Assigning runs the cascade, which fills the
+ other scopes' entries it still lacks and rebuilds ``merged_sd``,
+ ``df_data_dict`` and ``df_display_args``.
+
+ The cache entry goes in first because ``_populate_sd_cache`` skips a key it
+ finds, so the filt scope is not computed a second time. The tier is a
+ plain attribute, flipped before the compute so ``_get_summary_sd`` runs the
+ full pipeline, and put back if anything raises."""
+ tier = dataflow.stats_tier
+ key = _full_stats_key(dataflow)
+ dataflow.stats_tier = "full"
+ try:
+ sd = dataflow.summary_stats_cache.get(key)
+ errs = {}
+ if sd is None:
+ sd, errs = computed if computed is not None else dataflow._get_summary_sd(dataflow.processed_df)
+ dataflow.summary_stats_cache = {**dataflow.summary_stats_cache, key: sd}
+ dataflow.summary_sd = sd
+ dataflow.errs = errs
+ except Exception:
+ dataflow.stats_tier = tier
+ raise
+
+
+def _run_summary(dataflow: Any, run: StatRun) -> tuple:
+ """The ``(sd, errs)`` of a finished run, as ``_get_summary_sd`` returns them
+ (it raises on a failed stat in debug mode, and so does this)."""
+ errs = run.errs()
+ if errs and dataflow.debug:
+ raise Exception("Error executing analysis")
+ return run.raw_sd(), errs
+
+
+def _run_span_attrs(run: StatRun) -> dict:
+ """What the completion span says about a run that spanned requests: the
+ units it took and their summed time, and for a backend with a snapshot
+ cache (xorq) the outcome ``firstpull.summary_stats`` carries for a whole
+ run (#943)."""
+ attrs: Dict[str, Any] = {"units": len(run.units), "run_secs": round(run.elapsed_s, 4)}
+ cache_run_stats = getattr(run.stats, "cache_run_stats", None)
+ if cache_run_stats is not None:
+ cs = cache_run_stats()
+ attrs.update(cache_status=cs["status"], cache_hits=cs["hits"], cache_misses=cs["misses"],
+ cache_secs=attrs["run_secs"], cache_snapshots=cs["snapshots"], cache_bytes=cs["bytes"],
+ cache_write_errors=cs["write_errors"])
+ return attrs
+
+
+def _fail_stats(session: SessionState) -> None:
+ """A stats run failed: that is the session's state for this generation, so
+ no request retries it and the next generation starts clean."""
+ session.stats_status, session.stats_reason = "error", "stats_failed"
+ session.stat_runs.clear()
+
+
+def complete_stats(session: SessionState) -> bool:
+ """Run the stats a pending session is missing and publish them: the final
+ assignment (``assign_full_stats``), then the session snapshot refreshed and
+ the status set to ``complete`` in the same step, so ``all_stats`` and
+ ``df_meta.stats`` cannot disagree. Returns whether the session is complete.
+
+ When the generation has a ``StatRun``, its remaining units run (none if it
+ is finished) and its results are what is assigned, so a unit that a client
+ already ran is not run again. With no run the whole computation is one
+ ``_get_summary_sd`` call, which runs the same units in the same order. The
+ run is freed with the assignment.
+
+ A failure is the session's state for this generation (``error``, reason
+ ``stats_failed``) and is not retried by the next request; the next
+ generation starts clean."""
+ if session.stats_status == "complete":
+ return True
+ dataflow = session_dataflow(session)
+ if session.stats_status != "pending" or dataflow is None:
+ return False
+ run_key = (session.stats_gen, "raw")
+ with (
+ perf_log.telemetry_context(session.session_id, session.tele_sink),
+ perf_log.perf_span("firstpull.stats_total", session=session.session_id, stats_gen=session.stats_gen) as span,
+ ):
+ try:
+ run = session.stat_runs.get(run_key)
+ if run is not None and _cached_full_sd(dataflow) is None:
+ run_units(run, None, session_id=session.session_id)
+ span.set_attr(**_run_span_attrs(run))
+ assign_full_stats(dataflow, computed=_run_summary(dataflow, run))
+ else:
+ assign_full_stats(dataflow)
+ refresh_session_snapshot(session, dataflow)
+ except Exception:
+ log.error("stats run failed session=%s stats_gen=%s: %s", session.session_id, session.stats_gen,
+ traceback.format_exc())
+ _fail_stats(session)
+ return False
+ session.stat_runs.pop(run_key, None)
+ session.stats_status, session.stats_reason = "complete", None
+ return True
+
+
+def build_state_message_for(session: SessionState, client: Any, metadata: Optional[dict] = None) -> dict:
+ """The ``initial_state`` message for one client.
+
+ A client without the ``stats_update`` capability on a pending deferred
+ session gets its missing stats run first, so its message is complete; a
+ capable client gets the session snapshot as it is (stats-free while the
+ session is pending) and pulls the rest. The search term is the recipient's
+ own (#851).
+
+ A capable client is also told apart from the display config it holds: the
+ digest of the one a pending frame carried is kept on it, so the final
+ ``stats_update`` can say whether the config it would send differs."""
+ if (session.stats_delivery == "deferred" and session.stats_status == "pending"
+ and not client_has_cap(client, STATS_UPDATE_CAP)):
+ complete_stats(session)
+ if client_has_cap(client, STATS_UPDATE_CAP):
+ client.display_args_hash = (display_args_hash(session.df_display_args)
+ if session.stats_status == "pending" else None)
+ return build_state_message(session, metadata=metadata, search_string=getattr(client, "search_string", ""))
+
+
+def broadcast_state(session: SessionState, metadata: Optional[dict] = None, reset_search: bool = False) -> None:
+ """Send every connected client its own ``initial_state``. A client whose
+ write fails is dropped from the session.
+
+ ``reset_search`` clears each client's live search term first (#851), for a
+ push that replaces the dataset. Clients that merge ``stats_update`` go
+ first: a legacy client's message completes the session's stats, and a
+ message built after that would carry them, so the capable client would
+ never see the pending state its own frame is meant to describe."""
+ for client in sorted(session.ws_clients, key=lambda c: not client_has_cap(c, STATS_UPDATE_CAP)):
+ try:
+ if reset_search:
+ client.search_string = ""
+ client.write_message(json.dumps(build_state_message_for(session, client, metadata=metadata)))
+ except Exception:
+ session.ws_clients.discard(client)
+
+
+def _aborted(stats_gen: Any, scope: Any, reason: str, session: Optional[SessionState] = None) -> dict:
+ """``stats_aborted``: the request was not run. ``stats_gen`` echoes the
+ request so the client can pair them; ``current_gen`` is the session's, so a
+ stale client can ask again without waiting for the next ``initial_state``."""
+ msg: dict = {"type": "stats_aborted", "stats_gen": stats_gen, "scope": scope, "reason": reason}
+ if session is not None:
+ msg["current_gen"] = session.stats_gen
+ return msg
+
+
+def _elapsed_ms(started: float) -> float:
+ return round((time.perf_counter() - started) * 1000, 1)
+
+
+def _plan_run(session: SessionState) -> Optional[StatRun]:
+ """The session's ``StatRun`` for the current generation, or ``None`` when it
+ cannot be planned (there is no frame, or the dataflow's stats class cannot
+ plan the one it has: a post-processor that failed leaves an error frame).
+ The whole-run path then decides what the stats are."""
+ try:
+ return start_stat_run(session)
+ except Exception:
+ log.warning("stat units not planned session=%s stats_gen=%s: %s", session.session_id, session.stats_gen,
+ traceback.format_exc())
+ return None
+
+
+def _serve_units(session: SessionState, client: Any, prefer: Sequence[str], stats_gen: Any, scope: Any,
+ started: float, info: dict) -> Optional[dict]:
+ """The reply to an incremental request on a pending session, when the
+ answer is the fragments this client has not seen: a partial ``stats_update``
+ (``final`` false), or ``stats_aborted`` when a unit failed. ``None`` when
+ the request is not answered that way: the stats are cached or cannot be
+ planned (the whole-run path takes them), or the run is now finished and the
+ caller publishes it.
+
+ A client that is behind the run is caught up and no unit runs for it, so
+ work is done once and a client that asks late reads it all from its own
+ cursor."""
+ dataflow = session_dataflow(session)
+ if _cached_full_sd(dataflow) is not None:
+ return None
+ run = _plan_run(session)
+ if run is None:
+ return None
+ cursor = getattr(client, "stats_cursor", None) or StatCursor()
+ if cursor.caught_up(run):
+ try:
+ info["units"] = run_units(run, STATS_BUDGET_S, prefer, session_id=session.session_id)
+ except Exception:
+ log.error("stat unit failed session=%s stats_gen=%s: %s", session.session_id, session.stats_gen,
+ traceback.format_exc())
+ _fail_stats(session)
+ return _aborted(stats_gen, scope, "error", session)
+ fragments = cursor.take(run)
+ if run.status != "pending":
+ return None
+ return {"type": "stats_update", "stats_gen": stats_gen, "scope": scope, "tier": session.stats_tier,
+ "final": False, "remaining": run.remaining, "payload": partial_payload(dataflow, run, fragments),
+ "elapsed_ms": _elapsed_ms(started)}
+
+
+def _rebuilt_display_args(session: SessionState, client: Any) -> Optional[dict]:
+ """The session's display config, when it differs from the one this client
+ holds: the stats-derived parts of a config (a float column's ``minWidth``)
+ change when the stats arrive. The client's own highlight is kept on it,
+ since the config replaces the one that carries it. ``None`` when the client
+ holds the current config, or there is no record of one to compare with."""
+ held = getattr(client, "display_args_hash", None)
+ if held is None:
+ return None
+ client.display_args_hash = None
+ if display_args_hash(session.df_display_args) == held:
+ return None
+ term = getattr(client, "search_string", "")
+ return highlighted_display_args(session.df_display_args, term) if term else session.df_display_args
+
+
+def _answer_stats_request(session: Optional[SessionState], msg: dict, client: Any, started: float,
+ info: dict) -> dict:
+ stats_gen, scope = msg.get("stats_gen"), msg.get("scope", "raw")
+ columns = msg.get("columns")
+ prefer = [c for c in columns if isinstance(c, str)] if isinstance(columns, list) else []
+ incremental = msg.get("incremental")
+ dataflow = session_dataflow(session)
+ if session is None or dataflow is None:
+ return _aborted(stats_gen, scope, "no_data")
+ if stats_gen != session.stats_gen:
+ return _aborted(stats_gen, scope, "stale", session)
+ if scope not in STATS_SCOPES:
+ return _aborted(stats_gen, scope, "unsupported_scope", session)
+ if incremental is not None and not isinstance(incremental, bool):
+ return _aborted(stats_gen, scope, "bad_request", session)
+ if session.stats_status == "not_computed":
+ return _aborted(stats_gen, scope, "not_requestable", session)
+ if session.stats_status == "pending" and incremental:
+ reply = _serve_units(session, client, prefer, stats_gen, scope, started, info)
+ if reply is not None:
+ return reply
+ if session.stats_status == "pending":
+ complete_stats(session)
+ if session.stats_status != "complete":
+ return _aborted(stats_gen, scope, "error", session)
+ # Complete: from here the answer is the dataflow's own all_stats, with no
+ # query, whether this request ran the stats or an earlier one did.
+ reply = {"type": "stats_update", "stats_gen": stats_gen, "scope": scope, "tier": dataflow.stats_tier,
+ "final": True, "remaining": 0, "payload": session.df_data_dict["all_stats"],
+ "elapsed_ms": _elapsed_ms(started)}
+ display_args = _rebuilt_display_args(session, client)
+ if display_args is not None:
+ reply["df_display_args"] = display_args
+ return reply
+
+
+def handle_stats_request(session: Optional[SessionState], msg: dict, client: Any = None) -> dict:
+ """Answer a ``stats_request {stats_gen, scope, columns?, incremental?}``: a
+ ``stats_update`` or a ``stats_aborted``. ``client`` is the handler that
+ received it, which holds the connection's cursor into the run and the
+ display config it was last sent.
+
+ Without ``incremental`` the request is the whole run: every unit still to
+ run, then a ``stats_update`` that is ``final`` and carries the complete
+ ``all_stats`` as an inline wide ``DFEnvelope`` (self-contained, so binary
+ pairing stays single-slot). With ``incremental: true`` it is a time-boxed
+ step (see ``_serve_units``): a ``stats_update`` with ``final`` false
+ carries the stats of the columns this client has not seen, ``remaining``
+ counts the units left, and the reply that follows the last unit is the
+ ``final`` one, with the complete ``all_stats`` and, when the stats changed
+ it, the rebuilt ``df_display_args``. ``columns`` are the grid's column
+ names (``a, b, c``), a hint for which units go first, and a whole-run
+ request ignores it. A malformed request is ``stats_aborted`` with reason
+ ``bad_request``, and is not run as a whole run.
+
+ The ``stats.request`` span records the request and its ``outcome``
+ (``update`` or the abort reason), which is where updates sent, requests
+ dropped as stale and errors are counted. The caller binds the session's
+ telemetry sink around this call."""
+ stats_gen, scope, columns = msg.get("stats_gen"), msg.get("scope", "raw"), msg.get("columns")
+ started = time.perf_counter()
+ info: dict = {}
+ with perf_log.perf_span("stats.request", session=session.session_id if session else None, stats_gen=stats_gen,
+ scope=scope, columns=len(columns) if isinstance(columns, list) else None) as span:
+ try:
+ reply = _answer_stats_request(session, msg, client, started, info)
+ except Exception:
+ log.error("stats_request error session=%s: %s", session.session_id if session else None,
+ traceback.format_exc())
+ reply = _aborted(stats_gen, scope, "error", session)
+ if reply["type"] == "stats_update":
+ span.set_attr(outcome="update", tier=reply["tier"], final=reply["final"], remaining=reply["remaining"])
+ else:
+ span.set_attr(outcome=reply["reason"])
+ if "units" in info:
+ span.set_attr(units=info["units"])
+ return reply
diff --git a/buckaroo/server/websocket_handler.py b/buckaroo/server/websocket_handler.py
index 2232211c7..ec394f599 100644
--- a/buckaroo/server/websocket_handler.py
+++ b/buckaroo/server/websocket_handler.py
@@ -1,4 +1,3 @@
-import copy
import json
import logging
import os
@@ -9,8 +8,10 @@
import tornado.websocket
from buckaroo.pluggable_analysis_framework import perf_log
-from buckaroo.server.data_loading import (handle_infinite_request, handle_infinite_request_buckaroo, handle_infinite_request_lazy, get_buckaroo_display_state)
-from buckaroo.server.session import build_state_message
+from buckaroo.server.data_loading import (handle_infinite_request, handle_infinite_request_buckaroo, handle_infinite_request_lazy)
+from buckaroo.server.session import begin_stats_generation, dataflow_stats_tier
+from buckaroo.server.stat_run import StatCursor
+from buckaroo.server.stats_wire import (broadcast_state, build_state_message_for, handle_stats_request, highlighted_display_args, parse_caps, refresh_session_snapshot)
def _handle_infinite_request_xorq(xorq_dataflow, payload_args, search_string=""):
@@ -38,6 +39,18 @@ def open(self, session_id):
# highlight overlay below — never broadcast, never stored on the
# session.
self.search_string = ""
+ # Capabilities the client advertised on the URL (``?caps=a,b``). Recorded
+ # per connection because this method sends the first message before the
+ # client can say anything, and the other send sites push one shared
+ # snapshot (stats_wire.build_state_message_for reads it per client).
+ self.caps = parse_caps(self.get_query_argument("caps", ""))
+ # This client's position in the session's StatRun fragment list (the
+ # run itself is shared); it ends with the connection.
+ self.stats_cursor = StatCursor()
+ # Digest of the df_display_args a stats-free frame last sent this
+ # client, kept so the final stats_update can say whether the rebuilt
+ # config differs (stats_wire.build_state_message_for sets it).
+ self.display_args_hash = None
sessions = self.application.settings["sessions"]
sessions.add_ws_client(session_id, self)
@@ -45,7 +58,7 @@ def open(self, session_id):
# search_string="" — fresh connection, no per-client typing yet.
session = sessions.get(session_id)
if session and (session.df is not None or session.ldf is not None or session.xorq_dataflow is not None):
- self.write_message(json.dumps(build_state_message(session, search_string=self.search_string)))
+ self.write_message(json.dumps(build_state_message_for(session, self)))
def on_message(self, message):
try:
@@ -59,6 +72,24 @@ def on_message(self, message):
self._handle_infinite_request(msg.get("payload_args", {}))
elif msg_type == "buckaroo_state_change":
self._handle_buckaroo_state_change(msg.get("new_state") or {})
+ elif msg_type == "stats_request":
+ self._handle_stats_request(msg)
+
+ def _handle_stats_request(self, msg):
+ """Answer a client's ``stats_request`` with a ``stats_update`` or a
+ ``stats_aborted``. Synchronous, like ``infinite_request``: the request
+ runs its units in this call, a time-boxed few for an incremental one and
+ every unit left for the others (see ``stats_wire``), so a waiting
+ ``infinite_request`` is served between requests.
+
+ This branch is its own async context, so the session's telemetry sink
+ is bound here for the ``stats.request`` span and the stats spans under
+ it."""
+ sessions = self.application.settings["sessions"]
+ session = sessions.get(self.session_id)
+ with perf_log.telemetry_context(self.session_id, session.tele_sink if session else None):
+ reply = handle_stats_request(session, msg, self)
+ self.write_message(json.dumps(reply))
def _handle_buckaroo_state_change(self, new_state):
sessions = self.application.settings["sessions"]
@@ -99,6 +130,12 @@ def _handle_buckaroo_state_change(self, new_state):
log.debug("buckaroo_state_change no-op session=%s — skipping rebroadcast", self.session_id)
return
+ # A deferred session whose stats were completed is at the full tier;
+ # the change reruns the cascade at the schema tier again (13 ms
+ # against the full stats), and the stats follow as requests.
+ if session.stats_delivery == "deferred":
+ dataflow.stats_tier = dataflow_stats_tier(session.stats_tier, session.stats_delivery)
+
# Propagate changes to the dataflow (mirrors BuckarooWidgetBase._buckaroo_state)
if old_state.get("post_processing") != new_state.get("post_processing"):
dataflow.post_processing_method = new_state.get("post_processing", "")
@@ -108,39 +145,20 @@ def _handle_buckaroo_state_change(self, new_state):
dataflow.quick_command_args = new_state.get("quick_command_args", {})
# Re-extract state from the dataflow — same helper works for both
- # ServerDataflow and XorqServerDataflow (verified by probe).
- buckaroo_state = get_buckaroo_display_state(dataflow)
- session.df_display_args = buckaroo_state["df_display_args"]
- session.df_data_dict = buckaroo_state["df_data_dict"]
- session.df_meta = buckaroo_state["df_meta"]
+ # ServerDataflow and XorqServerDataflow (verified by probe). The
+ # state the stats describe has changed, so the generation moves on.
+ refresh_session_snapshot(session, dataflow)
+ begin_stats_generation(session)
# Strip search_string before snapshotting onto the session — it
# belongs to this client only (#851), so a future client that
# connects shouldn't inherit it via build_state_message.
session.buckaroo_state = {k: v for k, v in new_state.items() if k != "search_string"}
- session.buckaroo_options = buckaroo_state["buckaroo_options"]
- session.command_config = buckaroo_state["command_config"]
-
- # Re-apply component_config so theme settings survive state changes
- if session.component_config and session.df_display_args:
- for key in session.df_display_args:
- dvc = session.df_display_args[key].get("df_viewer_config")
- if dvc is not None:
- dvc["component_config"] = {
- **dvc.get("component_config", {}),
- **session.component_config,
- }
# Broadcast updated state to all connected clients. Each
# client gets its own search_string re-injected so a
# dataflow rebuild from one tab doesn't silently clear the
# search box on another (or on the typing client itself).
- for client in list(session.ws_clients):
- try:
- msg = build_state_message(session,
- search_string=getattr(client, "search_string", ""))
- client.write_message(json.dumps(msg))
- except Exception:
- session.ws_clients.discard(client)
+ broadcast_state(session)
except Exception:
tb = traceback.format_exc()
log.error("buckaroo_state_change error session=%s: %s", self.session_id, tb)
@@ -168,23 +186,16 @@ def _send_highlight_overlay(self, session):
if not session.df_display_args:
return
term = self.search_string
- overlay = copy.deepcopy(session.df_display_args)
- for dva in overlay.values():
- dvc = (dva or {}).get("df_viewer_config") or {}
- for col in dvc.get("column_config", []) or []:
- disp = col.get("displayer_args")
- if not isinstance(disp, dict) or disp.get("displayer") != "string":
- continue
- if term:
- disp["highlight_phrase"] = [term]
- else:
- disp.pop("highlight_phrase", None)
-
+ # Build the message first: for a client without the stats_update
+ # capability it completes the session's stats, which replaces
+ # session.df_display_args, and the overlay is a copy of that.
# Pass self.search_string so the overlay's buckaroo_state
# round-trips the typed term back to this client (Codex P1 on
# #854 — without it the JS clears the search box on every
# keystroke).
- msg = build_state_message(session, search_string=self.search_string)
+ msg = build_state_message_for(session, self)
+ overlay = highlighted_display_args(session.df_display_args, term)
+
msg["df_display_args"] = overlay
try:
self.write_message(json.dumps(msg))
diff --git a/buckaroo/server/xorq_loading.py b/buckaroo/server/xorq_loading.py
index 60a9226cf..f60c9cc0e 100644
--- a/buckaroo/server/xorq_loading.py
+++ b/buckaroo/server/xorq_loading.py
@@ -64,6 +64,11 @@ class XorqServerDataflow(XorqDataflow):
and project-authored post-processing classes discovered under
``/post_processing/*.py``; the built-in xorq stats are
kept first so collisions resolve to the built-in.
+
+ ``stat_chunk_cells`` is the host's request (off when ``None``) to run the
+ stats batch aggregate in chunks of about that many cells instead of one
+ query; the stats pipeline still refuses it for any source that is not a
+ plain parquet scan (``XorqStatPipeline.chunk_refusal``).
"""
sampling_klass = XorqInfiniteSampling
@@ -72,13 +77,14 @@ class XorqServerDataflow(XorqDataflow):
DFStatsClass = XorqDfStatsV2
analysis_klasses = _XORQ_ANALYSIS_KLASSES
- def __init__(self, expr, *args, extra_klasses=None, cache_storage_path=None, **kwargs):
+ def __init__(self, expr, *args, extra_klasses=None, cache_storage_path=None, stat_chunk_cells=None, **kwargs):
if extra_klasses:
# Per-instance override — class-level _XORQ_ANALYSIS_KLASSES is
# left untouched so other sessions / direct widget usage don't
# inherit one project's stats.
self.analysis_klasses = list(_XORQ_ANALYSIS_KLASSES) + list(extra_klasses)
self.cache_storage = _make_cache_storage(cache_storage_path)
+ self.stat_chunk_cells = stat_chunk_cells
super().__init__(expr, *args, **kwargs)
diff --git a/buckaroo/xorq_buckaroo.py b/buckaroo/xorq_buckaroo.py
index 6b9149c40..569cb8a06 100644
--- a/buckaroo/xorq_buckaroo.py
+++ b/buckaroo/xorq_buckaroo.py
@@ -14,7 +14,7 @@
import traceback
import weakref
from io import BytesIO
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING, Any, Optional, cast
import pandas as pd
@@ -27,13 +27,14 @@
from .buckaroo_widget import BuckarooInfiniteWidget, BuckarooWidget
from .customizations.styling import DefaultMainStyling, DefaultSummaryStatsStyling
from .customizations.xorq_autoclean_conf import NoCleaningConfXorq
-from .customizations.xorq_stats_v2 import XORQ_STATS_V2
+from .customizations.xorq_stats_v2 import XORQ_STATS_V2, schema_stats
from .dataflow.autocleaning import PandasAutocleaning
-from .dataflow.dataflow import CustomizableDataflow
+from .dataflow.dataflow import CustomizableDataflow, DfStats
from .dataflow.dataflow_extras import Sampling
from .df_util import old_col_new_col
from .pluggable_analysis_framework import perf_log
from .pluggable_analysis_framework.col_analysis import ColAnalysis
+from .pluggable_analysis_framework.stat_units import rewrite_sd
from .pluggable_analysis_framework.xorq_stat_pipeline import XorqDfStatsV2
from .serialization_utils import pd_to_obj, to_parquet, send_infinite_resp
@@ -145,8 +146,14 @@ class XorqDataflow(CustomizableDataflow["XorqExpr | pd.DataFrame"]):
2. ``_get_summary_sd`` re-keys the summary dict from original column
names (what ``XorqStatPipeline`` produces) to the rewritten
``a, b, c`` names that ``pd_to_obj`` and the styling layer expect.
+
+ ``stat_chunk_cells`` (off when ``None``) is a host's request to cut the
+ stats batch aggregate into chunks of that many cells; the stats pipeline
+ still refuses it for any source that is not a plain parquet scan.
"""
+ stat_chunk_cells: Optional[int] = None
+
def populate_df_meta(self) -> None:
if self.processed_df is None:
self.df_meta = {
@@ -173,15 +180,10 @@ def _get_summary_sd(self, processed_df: "XorqExpr | pd.DataFrame"):
'orig_col_name': orig_col,
'rewritten_col_name': rewritten_col}
return empty, {}
- cache_storage = getattr(self, 'cache_storage', None)
- # The owning widget injects XorqDfStatsV2 as DFStatsClass (via its
- # InnerDataFlow subclass); the cast exposes cache_run_stats() below.
- stats_klass = cast("type[XorqDfStatsV2]", self.DFStatsClass)
+ if self.stats_tier == "schema":
+ return self._get_schema_sd(processed_df), {}
with perf_log.perf_span("firstpull.summary_stats") as span:
- stats = stats_klass(
- processed_df, self.analysis_klasses, self.df_name,
- debug=self.debug, cache_storage=cache_storage,
- skip_columns=getattr(self, 'skip_stat_columns', None))
+ stats = cast("XorqDfStatsV2", self.build_stats(processed_df))
# Attach the summary-stat cache signal (#944) to the span so a
# telemetry consumer learns whether the stats were cached — the one
# signal only the server observes (#943). Carries the write side too
@@ -205,13 +207,44 @@ def _get_summary_sd(self, processed_df: "XorqExpr | pd.DataFrame"):
errs = stats.errs
else:
errs = {}
- rewritten = {}
- for orig_col, rewritten_col in old_col_new_col(processed_df):
- col_meta = dict(sdf.get(orig_col, {}))
- col_meta['orig_col_name'] = orig_col
- col_meta['rewritten_col_name'] = rewritten_col
- rewritten[rewritten_col] = col_meta
- return rewritten, errs
+ return rewrite_sd(sdf, processed_df), errs
+
+ def build_stats(self, processed_df: "XorqExpr | pd.DataFrame", run: bool = True) -> DfStats:
+ """The stats executor for an expression: as the base class builds it,
+ with the snapshot cache and the column-chunk split. The split needs the
+ row count, which is the cached count ``populate_df_meta`` already took,
+ and is looked up only when the split is on."""
+ # The owning widget injects XorqDfStatsV2 as DFStatsClass (via its
+ # InnerDataFlow subclass), whose constructor takes the extra keywords
+ # below, so it is typed as itself here and handed back as the protocol.
+ stats_klass = cast("type[XorqDfStatsV2]", self.DFStatsClass)
+ chunk_cells = self.stat_chunk_cells
+ return cast("DfStats", stats_klass(
+ processed_df, self.analysis_klasses, self.df_name,
+ debug=self.debug, cache_storage=getattr(self, 'cache_storage', None),
+ skip_columns=getattr(self, 'skip_stat_columns', None), chunk_cells=chunk_cells,
+ rows=_expr_count(processed_df) if chunk_cells else None, run=run))
+
+ def _get_schema_sd(self, processed_df: "XorqExpr | pd.DataFrame") -> dict:
+ """Identity, dtype, typing flags and row count for every column of an
+ expression, with no query beyond ``_expr_count`` (cached per
+ expression). Carries the same values full stats give those keys, so
+ styling that reads only them renders the same. A column in
+ ``skip_stat_columns`` gets only name, dtype and length, as at the full
+ tier, so its typing comes from ``init_sd``. ``_get_summary_sd`` handles
+ a pandas frame before it gets here."""
+ expr = cast("XorqExpr", processed_df)
+ schema = expr.schema()
+ length = _expr_count(expr)
+ skip = getattr(self, 'skip_stat_columns', None) or ()
+ sd: dict = {}
+ for orig_col, rewritten_col in old_col_new_col(expr):
+ dtype = str(schema[str(orig_col)])
+ typing = {'dtype': dtype} if orig_col in skip else schema_stats(dtype)
+ sd[rewritten_col] = {
+ 'orig_col_name': orig_col, 'rewritten_col_name': rewritten_col,
+ **typing, 'length': length}
+ return sd
_XORQ_ANALYSIS_KLASSES = list(XORQ_STATS_V2) + [DefaultSummaryStatsStyling, DefaultMainStyling]
diff --git a/tests/unit/basic_widget_test.py b/tests/unit/basic_widget_test.py
index a028751ed..8852b8cdd 100644
--- a/tests/unit/basic_widget_test.py
+++ b/tests/unit/basic_widget_test.py
@@ -288,6 +288,43 @@ def counting_handle(*args, **kwargs):
)
+def test_default_tier_widget_change_sequence_and_merged_sd():
+ """Characterization for the ``_handle_widget_change`` split (rows-first
+ s1): a default-tier pandas widget keeps the order in which a search change
+ reaches the dataflow's and widget's traits, and its merged_sd."""
+ bw = BuckarooWidget(pd.DataFrame({
+ 'price': [12.5, 18.9, 7.4, 22.1, 14.0], 'qty': [1, 2, 1, 3, 2],
+ 'category': ['a', 'b', 'a', 'c', 'b']}))
+ msd = bw.dataflow.merged_sd
+ assert {col: (sd['length'], sd['_type']) for col, sd in msd.items()} == {
+ 'a': (5, 'float'), 'b': (5, 'integer'), 'c': (5, 'string')}
+ # pandas 3 names the string dtype 'str' where pandas 2 says 'object'.
+ assert [msd[col]['dtype'] for col in 'ab'] == ['float64', 'int64']
+ assert msd['c']['dtype'] in ('object', 'str')
+ assert msd['a']['mean'] == pytest.approx(14.98)
+
+ seen = []
+ bw.dataflow.observe(lambda c: seen.append(('dataflow', c['name'])), names=[
+ 'summary_sd', 'merged_sd', 'widget_args_tuple', 'df_data_dict',
+ 'df_display_args', 'df_meta', 'operations', 'raw_sd_key', 'clean_sd_key',
+ 'filt_sd_key', 'summary_stats_cache', 'processed_result', 'cleaned'])
+ bw.observe(lambda c: seen.append(('widget', c['name'])),
+ names=['df_data_dict', 'df_display_args', 'df_meta', 'operations'])
+ bw.dataflow.quick_command_args = {'search': ['a']}
+ assert seen == [
+ ('widget', 'df_data_dict'), ('dataflow', 'df_data_dict'),
+ ('widget', 'df_display_args'), ('dataflow', 'df_display_args'),
+ ('dataflow', 'widget_args_tuple'), ('dataflow', 'merged_sd'),
+ ('dataflow', 'summary_sd'), ('dataflow', 'processed_result'),
+ ('widget', 'df_meta'), ('dataflow', 'df_meta'), ('dataflow', 'cleaned'),
+ ('dataflow', 'summary_stats_cache'), ('dataflow', 'widget_args_tuple'),
+ ('dataflow', 'merged_sd'), ('dataflow', 'filt_sd_key'),
+ ('widget', 'operations'), ('dataflow', 'operations')]
+ assert {col: (sd['length'], sd['filtered_length'])
+ for col, sd in bw.dataflow.merged_sd.items()} == {
+ 'a': (5, 2), 'b': (5, 2), 'c': (5, 2)}
+
+
def test_multi_index_cols() -> None:
df = get_multiindex_cols_df()
bw = BuckarooWidget(df)
diff --git a/tests/unit/dataflow/customizable_dataflow_test.py b/tests/unit/dataflow/customizable_dataflow_test.py
index 09b36eb70..909302c7a 100644
--- a/tests/unit/dataflow/customizable_dataflow_test.py
+++ b/tests/unit/dataflow/customizable_dataflow_test.py
@@ -75,6 +75,25 @@ def test_widget_instatiation():
assert dfc.df_data_dict['main'] == BASIC_DF_JSON_DATA
assert dfc.df_display_args['main']['df_viewer_config'] == DFVIEWER_CONFIG_DEFAULT
+def test_all_stats_and_display_args_are_built_separately(monkeypatch):
+ """``_handle_widget_change`` is the composition of separately callable
+ builders (rows-first s1): the stats payload can be rebuilt without
+ rebuilding column_config, and column_config without serializing stats."""
+ dfc = ACDFC(BASIC_DF)
+ _unused, processed_df, merged_sd = dfc.widget_args_tuple
+ serialized = []
+ original = dfc._sd_to_jsondf
+ monkeypatch.setattr(dfc, '_sd_to_jsondf', lambda sd: serialized.append(sd) or original(sd))
+
+ display_args = dfc._build_df_display_args(processed_df, merged_sd)
+ assert serialized == [], "building display args must not serialize all_stats"
+ assert display_args == dfc.df_display_args
+
+ data_dict = dfc._build_df_data_dict(processed_df, merged_sd)
+ assert serialized == [merged_sd]
+ assert data_dict == dfc.df_data_dict
+
+
def test_widget_operations_instatiation():
dfc = ACDFC(BASIC_DF)
# dfc starts with operations of [{'meta':'no-op'], but the first
diff --git a/tests/unit/dataflow/scoped_summary_stats_test.py b/tests/unit/dataflow/scoped_summary_stats_test.py
index b5648ab56..bf908c6a4 100644
--- a/tests/unit/dataflow/scoped_summary_stats_test.py
+++ b/tests/unit/dataflow/scoped_summary_stats_test.py
@@ -15,6 +15,8 @@
observable in ``merged_sd``.
"""
+import copy
+
import pandas as pd
from typing import Any, TypedDict
@@ -23,7 +25,10 @@
from buckaroo.customizations.pd_stats_v2 import PD_ANALYSIS_V2, PD_AUTOCLEAN_DEFAULT_V2, cleaning_gen_ops
from buckaroo.dataflow.autocleaning import (AutocleaningConfig, PandasAutocleaning)
from buckaroo.dataflow.dataflow import CustomizableDataflow, StylingAnalysis
+from buckaroo.dataflow.sd_cache import split_chain_by_scope
+from buckaroo.pluggable_analysis_framework.col_analysis import ColAnalysis
from buckaroo.pluggable_analysis_framework.stat_func import stat
+from buckaroo.pluggable_analysis_framework.stat_units import merge_fragments, rewrite_sd
_AddOrigResult = TypedDict('_AddOrigResult', {'cleaning_ops': Any, 'add_orig': Any})
@@ -249,3 +254,98 @@ def test_analysis_klasses_change_invalidates_scoped_sd():
f"got the same key {key1} for both — likely the cache key still "
f"omits analysis_klasses identity (Codex P2)"
)
+
+
+def _scope_inputs(dfc):
+ """The pieces ``_merged_sd`` assembles, read back from the dataflow."""
+ cache = dfc.summary_stats_cache
+ return dict(
+ init_sd=dfc.init_sd, cleaned_sd=dfc.cleaned_sd, raw_sd=cache[dfc.raw_sd_key],
+ processed_sd=dfc.processed_sd, processed_df=dfc.processed_df,
+ chains=split_chain_by_scope(dfc.operations),
+ clean_sd=cache[dfc.clean_sd_key], filt_sd=cache[dfc.filt_sd_key])
+
+
+def _assembly_dataflow():
+ df = pd.DataFrame({'amount': ['10', '20', '30', '40', '50', '60', '70'],
+ 'word': ['foo', 'bar', 'foo', 'baz', 'foo', 'bar', 'foo']})
+ # init_sd is keyed by the original column name; 'amount' is rewritten to 'a'.
+ dfc = ScopedDataflow(df, init_sd={
+ 'amount': {'displayer_args': {'displayer': 'string', 'max_length': 99}, 'init_only': 1}})
+ dfc.cleaning_method = 'default'
+ dfc.quick_command_args = {'search': ['foo']}
+ return dfc
+
+
+def test_assemble_merged_sd_equals_merged_sd_with_init_sd_and_cleaning():
+ """``assemble_merged_sd`` is the body of the ``merged_sd`` observer as a
+ pure function, so a server can assemble wire stats from an accumulating
+ raw sd without going through the traitlets cascade."""
+ from buckaroo.dataflow.dataflow import assemble_merged_sd
+ dfc = _assembly_dataflow()
+ sd = dfc.merged_sd['a']
+ assert sd['init_only'] == 1, "init_sd override must reach merged_sd under the rewritten name"
+ assert 'cleaned_mean' in sd and 'filtered_mean' in sd, "cleaning and filter layers must be active"
+
+ assembled = assemble_merged_sd(**_scope_inputs(dfc))
+
+ assert assembled == dfc.merged_sd
+
+
+def test_assemble_merged_sd_without_a_processed_df_merges_the_unrewritten_sds():
+ from buckaroo.dataflow.dataflow import assemble_merged_sd
+ assembled = assemble_merged_sd(
+ init_sd={'x': {'init': 1}}, cleaned_sd={'x': {'cleaned': 2}}, raw_sd={'x': {'raw': 3}},
+ processed_sd={'x': {'processed': 4}}, processed_df=None,
+ chains={'raw': [], 'clean': [], 'filt': []})
+ assert assembled == {'x': {'init': 1, 'cleaned': 2, 'raw': 3, 'processed': 4}}
+
+
+def test_assemble_merged_sd_does_not_mutate_its_inputs():
+ from buckaroo.dataflow.dataflow import assemble_merged_sd
+ dfc = _assembly_dataflow()
+ inputs = _scope_inputs(dfc)
+ sd_names = ('init_sd', 'cleaned_sd', 'raw_sd', 'processed_sd', 'clean_sd', 'filt_sd')
+
+ def key_sets():
+ return {name: {col: sorted(stats) for col, stats in inputs[name].items()} for name in sd_names}
+
+ before, chains_before = key_sets(), copy.deepcopy(inputs['chains'])
+
+ assemble_merged_sd(**inputs)
+
+ # Layering cleaned_* / filtered_* into a result column that shared its dict
+ # with an input would add keys to that input.
+ assert key_sets() == before
+ assert inputs['chains'] == chains_before
+
+
+class _OverridingPostProcessing(ColAnalysis):
+ """A post-processing method whose sd overrides a stat of qty (keyed by the
+ original column name) and adds one of its own."""
+ provides_defaults = {}
+ post_processing_method = "override_post"
+
+ @classmethod
+ def post_process_df(cls, df):
+ return [df, {"qty": {"mean": 99.5, "from_post": 1}}]
+
+
+def _run_units(stats):
+ """Run every unit of a stats object built with ``run=False``, one at a time.
+ Returns the accumulator and the fragments in the order they came back."""
+ state = stats.state
+ acc = stats.new_accumulator(state)
+ return acc, [stats.run(unit, acc) for unit in stats.plan(state)]
+
+
+def _scope_sds_by_units(dataflow):
+ """Each scope's summary dict, built from the fragments of its planned units
+ where the dataflow built it by running the stats class whole."""
+ sds = {}
+ for scope in ("raw", "clean", "filt"):
+ scope_df = dataflow.processed_df if scope == "filt" else dataflow._compute_scope_df(scope)
+ stats = dataflow.build_stats(scope_df, run=False)
+ _acc, fragments = _run_units(stats)
+ sds[scope] = rewrite_sd(merge_fragments(fragments), stats.state.data)
+ return sds
diff --git a/tests/unit/server/test_data_loading_polars.py b/tests/unit/server/test_data_loading_polars.py
index 58f06f5bb..ed4bcfd16 100644
--- a/tests/unit/server/test_data_loading_polars.py
+++ b/tests/unit/server/test_data_loading_polars.py
@@ -9,15 +9,43 @@
* ``.json`` files are read as standard JSON arrays (matches
``pd.read_json`` default ``lines=False`` so the same file loads under
either backend).
+
+It also pins the schema stats tier (rows-first s2) for the pandas and
+polars server dataflows side by side: ``stats_tier="schema"`` publishes
+the display state full stats give, without reading a value.
"""
+import base64
+import dataclasses
+import datetime
+import decimal
+import inspect
+import io
import json
import os
import tempfile
+import threading
+import pandas as pd
import polars as pl
+import pyarrow.parquet as pq
import pytest
+from tornado.ioloop import IOLoop
-from buckaroo.server.data_loading_polars import (create_polars_dataflow, handle_infinite_request_buckaroo_polars, load_file_polars)
+from buckaroo.dataflow.dataflow import assemble_merged_sd
+from buckaroo.dataflow.sd_cache import split_chain_by_scope
+from buckaroo.jlisp.lisp_utils import s as lisp_sym
+from buckaroo.pluggable_analysis_framework import perf_log
+from buckaroo.pluggable_analysis_framework.col_analysis import ColAnalysis
+from buckaroo.pluggable_analysis_framework.stat_pipeline import StatPipeline
+from buckaroo.pluggable_analysis_framework.stat_units import StatAccumulator, StatState, StatUnit, merge_fragments
+from buckaroo.pluggable_analysis_framework.utils import PERVERSE_DF
+from buckaroo.serialization_utils import resolve_summary_stats_payload
+from buckaroo.server import stats_wire
+from buckaroo.server.data_loading import ServerDataflow, handle_infinite_request_buckaroo
+from buckaroo.server.data_loading_polars import (PolarsServerDataflow, create_polars_dataflow, handle_infinite_request_buckaroo_polars, load_file_polars)
+from buckaroo.server.stat_run import StatCursor, StatRun
+from buckaroo.styling_helpers import float_, obj_
+from tests.unit.dataflow.scoped_summary_stats_test import (_OverridingPostProcessing, _run_units, _scope_inputs, _scope_sds_by_units)
def _payload(start=0, end=100):
@@ -89,3 +117,799 @@ def test_load_file_polars_ndjson_still_works():
def test_load_file_polars_unsupported_extension():
with pytest.raises(ValueError, match="Unsupported file format"):
load_file_polars("/tmp/foo.xyz")
+
+
+# ---------------------------------------------------------------------------
+# stats_tier="schema" on the pandas and polars server dataflows (rows-first s2)
+# ---------------------------------------------------------------------------
+
+_BACKENDS = {
+ "pandas": (ServerDataflow, pd.DataFrame, handle_infinite_request_buckaroo),
+ "polars": (PolarsServerDataflow, pl.DataFrame, handle_infinite_request_buckaroo_polars)}
+
+# price has a 1e9 maximum, so its estimated column width depends on the min and
+# max that only full stats supply.
+_TIER_DATA = {
+ "price": [12.5, 18.9, 7.4, 22.1, 1e9],
+ "qty": [1, 2, 1, 3, 2],
+ "category": ["a", "b", "a", "c", "b"]}
+
+# The same columns with a different number of rows per value in every column.
+# polars does not keep the order of equal counts, so on _TIER_DATA the mode of
+# two full-stats runs over the same frame can differ.
+_UNTIED_DATA = {
+ "price": [12.5, 12.5, 12.5, 18.9, 18.9, 1e9],
+ "qty": [1, 1, 1, 2, 2, 3],
+ "category": ["a", "a", "a", "b", "b", "c"]}
+
+# Stats that read values. The schema tier must not publish any of them.
+_DATA_STAT_KEYS = {
+ "mean", "std", "median", "min", "max", "null_count", "non_null_count",
+ "distinct_count", "unique_count", "value_counts", "mode", "histogram",
+ "histogram_bins", "memory_usage"}
+
+
+@pytest.fixture(params=list(_BACKENDS))
+def backend(request):
+ return request.param
+
+
+def _frame(backend, data=None):
+ return _BACKENDS[backend][1](_TIER_DATA if data is None else data)
+
+
+def _typed_frame(backend):
+ """One column per kind of dtype the typing stats tell apart."""
+ days = [datetime.date(2020, 1, 1), datetime.date(2020, 1, 2), datetime.date(2020, 1, 3)]
+ if backend == "pandas":
+ return pd.DataFrame({
+ "float": [1.5, 2.5, 3.5], "int": [1, 2, 3],
+ "nullable_int": pd.array([1, None, 3], dtype="Int64"),
+ "bool": [True, False, True], "str": ["a", "b", "c"],
+ "datetime": pd.to_datetime(days), "datetime_utc": pd.to_datetime(days, utc=True),
+ "timedelta": pd.to_timedelta([1, 2, 3], unit="D"),
+ "category": pd.Categorical(["a", "b", "a"])})
+ return pl.DataFrame({
+ "float": [1.5, 2.5, 3.5], "int32": pl.Series([1, 2, 3], dtype=pl.Int32),
+ "bool": [True, False, True], "str": ["a", "b", "c"], "date": days,
+ "datetime": [datetime.datetime(2020, 1, d) for d in (1, 2, 3)],
+ "duration": [datetime.timedelta(days=d) for d in (1, 2, 3)],
+ "time": [datetime.time(h) for h in (1, 2, 3)],
+ "decimal": pl.Series([decimal.Decimal("1.5")] * 3, dtype=pl.Decimal(10, 2)),
+ "binary": [b"a", b"b", b"c"],
+ "category": pl.Series(["a", "b", "a"], dtype=pl.Categorical)})
+
+
+def _build_dataflow(backend, frame=None, **kwargs):
+ frame = _frame(backend) if frame is None else frame
+ return _BACKENDS[backend][0](frame, skip_main_serial=True, **kwargs)
+
+
+def _three_scope_dataflow(backend, data=None, **kwargs):
+ """A dataflow with a search (filt scope) active and, on pandas, a user op
+ (clean scope), so each scope holds its own summary-stats cache entry. The
+ polars conf ships no command to run outside a search, so there raw and
+ clean are one scope."""
+ dataflow = _build_dataflow(backend, _frame(backend, data), **kwargs)
+ if backend == "pandas":
+ dataflow.operations = [[lisp_sym("fillna"), {"symbol": "df"}, "qty", 0]]
+ dataflow.quick_command_args = {"search": ["a"]}
+ return dataflow
+
+
+def _scope_keys(dataflow, tier):
+ chains = split_chain_by_scope(dataflow.operations)
+ return {scope: dataflow._scope_cache_key(chain, tier=tier) for scope, chain in chains.items()}
+
+
+def _spy_stat_pipeline(monkeypatch):
+ """Record the columns of every frame ``StatPipeline.process_df`` runs on,
+ apart from the ``PERVERSE_DF`` its DAG self-check uses, which has no part
+ of the loaded data in it."""
+ frames = []
+ original = StatPipeline.process_df
+
+ def spy(self, df, *args, **kwargs):
+ if list(df.columns) != list(PERVERSE_DF.columns):
+ frames.append(list(df.columns))
+ return original(self, df, *args, **kwargs)
+
+ monkeypatch.setattr(StatPipeline, "process_df", spy)
+ return frames
+
+
+def _without_min_width(column_config):
+ return [{**cc, "ag_grid_specs": {k: v for k, v in cc["ag_grid_specs"].items() if k != "minWidth"}}
+ for cc in column_config]
+
+
+def _as_json(sd):
+ """Comparable form of an sd: json equates NaN with NaN where == would not."""
+ return json.dumps(sd, sort_keys=True, default=str)
+
+
+def _wire_stat_names(dataflow):
+ """The stat names in the ``all_stats`` payload sent to the client."""
+ envelope = dataflow.df_data_dict["all_stats"]
+ table = pq.read_table(io.BytesIO(base64.b64decode(envelope["data"])))
+ return {name.split("__", 1)[1] for name in table.column_names}
+
+
+def _viewer_config(dataflow, display="main"):
+ return dataflow.df_display_args[display]["df_viewer_config"]
+
+
+class _NoopPostProcessing(ColAnalysis):
+ provides_defaults = {}
+ post_processing_method = "noop_post"
+
+ @classmethod
+ def post_process_df(cls, df):
+ return [df, {}]
+
+
+class TestStatsTierSchema:
+ """``stats_tier="schema"`` on ``ServerDataflow`` and
+ ``PolarsServerDataflow``: every column is typed from its dtype alone, so
+ the dataflow publishes the display state full stats give with no stat
+ computed on the data."""
+
+ def test_matches_full_stats_display_state(self, backend):
+ full = _build_dataflow(backend)
+ schema = _build_dataflow(backend, stats_tier="schema")
+ assert schema.df_display_args.keys() == full.df_display_args.keys()
+ for name, full_arg in full.df_display_args.items():
+ schema_arg = schema.df_display_args[name]
+ assert schema_arg["data_key"] == full_arg["data_key"]
+ assert schema_arg["summary_stats_key"] == full_arg["summary_stats_key"]
+ full_cfg, schema_cfg = full_arg["df_viewer_config"], schema_arg["df_viewer_config"]
+ assert schema_cfg["pinned_rows"] == full_cfg["pinned_rows"]
+ assert (_without_min_width(schema_cfg["column_config"])
+ == _without_min_width(full_cfg["column_config"]))
+
+ def test_host_supplied_pinned_rows_match_full_stats(self, backend):
+ """pinned_rows is configuration, so it is the same in every tier even
+ when it names a stat the schema tier does not compute."""
+ pinned = [obj_("dtype"), float_("mean")]
+ full = _build_dataflow(backend, pinned_rows=pinned)
+ schema = _build_dataflow(backend, stats_tier="schema", pinned_rows=pinned)
+ assert _viewer_config(schema)["pinned_rows"] == _viewer_config(full)["pinned_rows"] == pinned
+
+ def test_min_width_is_the_stats_derived_difference(self, backend):
+ widths = {}
+ for tier in ("full", "schema"):
+ cfg = _viewer_config(_build_dataflow(backend, stats_tier=tier))
+ widths[tier] = {cc["header_name"]: cc["ag_grid_specs"]["minWidth"]
+ for cc in cfg["column_config"]}
+ # price's 1e9 maximum widens it under full stats; without min and max
+ # the estimate falls back to a one-digit value.
+ assert widths["schema"]["price"] < widths["full"]["price"]
+
+ def test_typing_matches_full_stats_for_every_dtype(self, backend):
+ full = _build_dataflow(backend, _typed_frame(backend))
+ schema = _build_dataflow(backend, _typed_frame(backend), stats_tier="schema")
+ assert schema.merged_sd.keys() == full.merged_sd.keys()
+ for col, stats in schema.merged_sd.items():
+ assert {"orig_col_name", "rewritten_col_name", "dtype", "_type", "length"} <= stats.keys()
+ assert stats == {k: full.merged_sd[col][k] for k in stats}
+ assert {"duration", "categorical", "datetime"} <= {
+ stats["_type"] for stats in schema.merged_sd.values()}
+
+ def test_publishes_no_data_derived_stat(self, backend):
+ full = _build_dataflow(backend)
+ schema = _build_dataflow(backend, stats_tier="schema")
+ for stats in schema.merged_sd.values():
+ assert not _DATA_STAT_KEYS & stats.keys()
+ assert _wire_stat_names(schema) == {"dtype"}
+ # The same reads on the full tier see the stats, so they would notice.
+ assert _DATA_STAT_KEYS & full.merged_sd["a"].keys()
+ assert {"dtype", "histogram_bins"} <= _wire_stat_names(full)
+
+ def test_computes_no_stat_on_the_data(self, backend, monkeypatch):
+ frames = _spy_stat_pipeline(monkeypatch)
+ dataflow = _build_dataflow(backend, stats_tier="schema")
+ dataflow.quick_command_args = {"search": ["a"]}
+ dataflow.add_analysis(_NoopPostProcessing)
+ assert frames == []
+ assert "noop_post" in dataflow.buckaroo_options["post_processing"]
+
+ def test_the_spy_sees_the_pipeline_at_the_full_tier(self, backend, monkeypatch):
+ frames = _spy_stat_pipeline(monkeypatch)
+ _build_dataflow(backend)
+ assert frames, "the spy would not notice a stat computed on the data"
+
+ def test_init_sd_hints_and_overrides_still_apply(self, backend):
+ dataflow = _build_dataflow(
+ backend, stats_tier="schema",
+ init_sd={"qty": {"displayer_args": {"displayer": "string", "max_length": 200}}},
+ column_config_overrides={
+ "category": {"displayer_args": {"displayer": "string", "max_length": 5000}}})
+ by_header = {cc["header_name"]: cc for cc in _viewer_config(dataflow)["column_config"]}
+ assert by_header["qty"]["displayer_args"]["max_length"] == 200
+ assert by_header["category"]["displayer_args"]["max_length"] == 5000
+
+ def test_skipped_column_keeps_init_sd_typing(self, backend):
+ """A column in ``skip_stat_columns`` gets only its names from the
+ pipeline, so its ``_type`` comes from ``init_sd``. The schema tier
+ must not layer the dtype-derived typing keys over it."""
+ kwargs = {
+ "init_sd": {"qty": {"_type": "float", "mean": 1.8, "min": 1, "max": 3}},
+ "skip_stat_columns": ["qty"]}
+ full = _build_dataflow(backend, **kwargs)
+ schema = _build_dataflow(backend, stats_tier="schema", **kwargs)
+
+ def merged(dataflow, orig_col):
+ return next(v for v in dataflow.merged_sd.values() if v["orig_col_name"] == orig_col)
+
+ assert merged(full, "qty")["_type"] == "float"
+ assert merged(schema, "qty")["_type"] == "float"
+ assert not {"is_numeric", "is_integer", "is_float", "dtype"} & merged(schema, "qty").keys()
+ assert merged(schema, "price")["_type"] == "float"
+ assert merged(schema, "price")["is_float"] is True
+ assert (_without_min_width(_viewer_config(schema)["column_config"])
+ == _without_min_width(_viewer_config(full)["column_config"]))
+
+ def test_empty_frame_matches_full_stats(self, backend):
+ """Full stats publish nothing for a frame with no rows (the pipeline
+ returns an empty summary), so the schema tier does the same."""
+ empty = {"x": [], "y": []}
+ full = _build_dataflow(backend, _frame(backend, empty))
+ schema = _build_dataflow(backend, _frame(backend, empty), stats_tier="schema")
+ assert full.merged_sd == {}
+ assert schema.merged_sd == {}
+ assert schema.df_display_args == full.df_display_args
+
+ def test_sorted_infinite_request_works(self, backend):
+ dataflow = _build_dataflow(backend, stats_tier="schema")
+ qty = next(k for k, v in dataflow.merged_sd.items() if v["orig_col_name"] == "qty")
+ handler = _BACKENDS[backend][2]
+ resp, parquet = handler(dataflow, {"start": 0, "end": 5, "sourceName": "default",
+ "sort": qty, "sort_direction": "desc"})
+ assert "error_info" not in resp
+ assert resp["length"] == 5
+ assert pq.read_table(io.BytesIO(parquet)).column(qty).to_pylist() == [3, 2, 2, 1, 1]
+
+ def test_pending_state_writes_no_full_tier_cache_key(self, backend):
+ dataflow = _three_scope_dataflow(backend, stats_tier="schema")
+ full_keys = set(_scope_keys(dataflow, "full").values())
+ assert len(full_keys) == (3 if backend == "pandas" else 2)
+ assert dataflow.summary_stats_cache
+ assert not full_keys & dataflow.summary_stats_cache.keys()
+
+ def test_later_full_assignment_reaches_merged_sd_for_all_scopes(self, backend):
+ full = _three_scope_dataflow(backend, _UNTIED_DATA)
+ dataflow = _three_scope_dataflow(backend, _UNTIED_DATA, stats_tier="schema")
+ assert "mean" not in dataflow.merged_sd["a"]
+ assert {"mean", "filtered_mean"} <= full.merged_sd["a"].keys()
+
+ dataflow.stats_tier = "full"
+ dataflow.summary_sd = full.summary_sd
+
+ assert _as_json(dataflow.merged_sd) == _as_json(full.merged_sd)
+
+ def test_full_scope_sds_cached_first_are_used_without_the_pipeline(self, backend, monkeypatch):
+ full = _three_scope_dataflow(backend)
+ dataflow = _three_scope_dataflow(backend, stats_tier="schema")
+ cache = dict(dataflow.summary_stats_cache)
+ for scope, key in _scope_keys(dataflow, "full").items():
+ cache[key] = full.summary_stats_cache[getattr(full, f"{scope}_sd_key")]
+ dataflow.summary_stats_cache = cache
+ frames = _spy_stat_pipeline(monkeypatch)
+
+ dataflow.stats_tier = "full"
+ dataflow.summary_sd = full.summary_sd
+
+ assert frames == []
+ assert _as_json(dataflow.merged_sd) == _as_json(full.merged_sd)
+
+ def test_assembled_sd_equals_merged_sd_with_init_sd_and_a_user_op(self, backend):
+ # init_sd is keyed by the original column name; price is rewritten to "a".
+ dataflow = _three_scope_dataflow(backend, stats_tier="schema", init_sd={
+ "price": {"displayer_args": {"displayer": "string", "max_length": 99}, "init_only": 1}})
+ sd = dataflow.merged_sd["a"]
+ assert sd["init_only"] == 1, "init_sd must reach merged_sd under the rewritten name"
+ assert "filtered_length" in sd, "the filter layer must be active"
+ if backend == "pandas":
+ assert "cleaned_length" in sd, "the cleaning layer must be active"
+
+ assembled = assemble_merged_sd(**_scope_inputs(dataflow))
+
+ assert _as_json(assembled) == _as_json(dataflow.merged_sd)
+
+
+def test_object_column_is_typed_as_a_string_at_the_schema_tier():
+ """pandas gives an object column no dtype to tell strings from other
+ values, and calls an empty one a string, so the schema tier types every
+ object column as one. Full stats read the values and call the mixed column
+ ``obj``; the gap closes when full stats arrive."""
+ def frame():
+ return pd.DataFrame({
+ "words": pd.Series(["a", "b", "c"], dtype=object),
+ "mixed": pd.Series([1, "b", 2.5], dtype=object)})
+
+ types = {}
+ for tier in ("full", "schema"):
+ sd = ServerDataflow(frame(), skip_main_serial=True, stats_tier=tier).merged_sd
+ types[tier] = {v["orig_col_name"]: v["_type"] for v in sd.values()}
+ assert types["full"] == {"words": "string", "mixed": "obj"}
+ assert types["schema"] == {"words": "string", "mixed": "string"}
+
+
+# ---------------------------------------------------------------------------
+# Resumable stat units on the pandas and polars server dataflows (rows-first s4)
+# ---------------------------------------------------------------------------
+
+
+class TestStatUnits:
+ """``plan`` and ``run`` on the stats class a dataflow builds, in place of
+ the all-at-once constructor."""
+
+ def test_build_stats_without_running_computes_nothing(self, backend, monkeypatch):
+ dataflow = _build_dataflow(backend)
+ frames = _spy_stat_pipeline(monkeypatch)
+ stats = dataflow.build_stats(dataflow.processed_df, run=False)
+ stats.plan(stats.state)
+ assert frames == []
+ assert stats.sdf == {} and stats.errs == {}
+
+ def test_the_default_run_goes_through_the_units(self, backend, monkeypatch):
+ """Every column the constructor computes is computed inside a unit, so
+ the inline path and a caller running units one at a time cannot
+ differ. (The DAG self-check runs ``PERVERSE_DF`` through the same
+ units, which has no part of the loaded data in it.)"""
+ depth, inside, outside, unit_columns = [0], [], [], []
+ original_run, original_column = StatPipeline.run, StatPipeline.process_column
+
+ def spy_run(self, unit, acc):
+ depth[0] += 1
+ if list(acc.state.data.columns) != list(PERVERSE_DF.columns):
+ unit_columns.append(unit.columns)
+ try:
+ return original_run(self, unit, acc)
+ finally:
+ depth[0] -= 1
+
+ def spy_column(self, *args, **kwargs):
+ (inside if depth[0] else outside).append(kwargs.get("column_name"))
+ return original_column(self, *args, **kwargs)
+
+ monkeypatch.setattr(StatPipeline, "run", spy_run)
+ monkeypatch.setattr(StatPipeline, "process_column", spy_column)
+ _three_scope_dataflow(backend)
+ assert {c for cols in unit_columns for c in cols} == {"price", "qty", "category"}
+ assert inside and outside == [], "a column's stats ran outside a unit"
+
+ def test_units_assembled_equal_merged_sd_with_init_sd_cleaning_and_overrides(self, backend):
+ """The fragments of each scope's units, assembled by
+ ``assemble_merged_sd`` with ``init_sd``, a cleaning op (pandas), a
+ search filter and a post-processing override, are the dataflow's own
+ ``merged_sd``. init_sd is keyed by the original name; price is "a"."""
+ dataflow = _three_scope_dataflow(backend, _UNTIED_DATA, init_sd={
+ "price": {"displayer_args": {"displayer": "string", "max_length": 99}, "init_only": 1}},
+ column_config_overrides={"category": {"displayer_args": {"displayer": "string", "max_length": 5000}}})
+ dataflow.add_analysis(_OverridingPostProcessing)
+ dataflow.post_processing_method = "override_post"
+ sd = dataflow.merged_sd
+ assert sd["a"]["init_only"] == 1 and sd["b"]["from_post"] == 1 and sd["b"]["mean"] == 99.5
+ assert "filtered_length" in sd["a"], "the filter layer must be active"
+ if backend == "pandas":
+ assert "cleaned_length" in sd["a"], "the cleaning layer must be active"
+
+ sds = _scope_sds_by_units(dataflow)
+ inputs = _scope_inputs(dataflow)
+ assembled = assemble_merged_sd(init_sd=inputs["init_sd"], cleaned_sd=inputs["cleaned_sd"], raw_sd=sds["raw"],
+ processed_sd=inputs["processed_sd"], processed_df=inputs["processed_df"], chains=inputs["chains"],
+ clean_sd=sds["clean"], filt_sd=sds["filt"])
+
+ assert _as_json(assembled) == _as_json(dataflow.merged_sd)
+
+ def test_a_column_group_request_returns_only_those_columns(self, backend):
+ dataflow = _build_dataflow(backend, _frame(backend, _UNTIED_DATA))
+ stats = dataflow.build_stats(dataflow.processed_df, run=False)
+ for names in (("qty", "category"), ("b", "c")): # original or rewritten names
+ state = dataclasses.replace(stats.state, columns=names)
+ acc = stats.new_accumulator(state)
+ fragments = [stats.run(unit, acc) for unit in stats.plan(state)]
+ assert [list(f) for f in fragments] == [["qty"], ["category"]]
+ assert list(acc.sd()) == ["qty", "category"]
+
+ def test_skip_stat_columns_get_no_unit(self, backend):
+ dataflow = _build_dataflow(backend, _frame(backend, _UNTIED_DATA), skip_stat_columns=["qty"],
+ init_sd={"qty": {"_type": "float"}})
+ stats = dataflow.build_stats(dataflow.processed_df, run=False)
+ assert stats.state.skip_columns == {"qty"}
+ assert [u.columns for u in stats.plan(stats.state)] == [("price",), ("category",)]
+ acc, fragments = _run_units(stats)
+ assert all("qty" not in f for f in fragments)
+ assert set(acc.sd()) == {"price", "qty", "category"}
+
+
+def _stat_run(backend, gen=4):
+ dataflow = _build_dataflow(backend, _frame(backend, _UNTIED_DATA))
+ return StatRun(gen, "raw", dataflow.build_stats(dataflow.processed_df, run=False)), dataflow
+
+
+# Original names that are also rewritten names of other columns: the rewritten
+# names are c -> a, b -> b, a -> c.
+_PERMUTED_DATA = {
+ "c": [12.5, 12.5, 12.5, 18.9, 18.9, 1e9],
+ "b": [1, 1, 1, 2, 2, 3],
+ "a": ["a", "a", "a", "b", "b", "c"]}
+
+
+def _permuted_stat_run(backend, gen=4):
+ dataflow = _build_dataflow(backend, _frame(backend, _PERMUTED_DATA))
+ return StatRun(gen, "raw", dataflow.build_stats(dataflow.processed_df, run=False)), dataflow
+
+
+class TestStatRun:
+ """The run a session holds for one generation and scope: the planned
+ units, the fragments they produced in the order they finished, and the
+ accumulator the units read. It runs a unit only when asked."""
+
+ def test_a_run_is_keyed_by_generation_and_scope_and_plans_its_units(self, backend):
+ run, _dataflow = _stat_run(backend)
+ assert run.key == (4, "raw")
+ assert run.status == "pending" and run.remaining == 3
+ assert [u.columns for u in run.units] == [("price",), ("qty",), ("category",)]
+ assert run.fragments == [] and run.ran == []
+
+ def test_fragments_are_appended_in_completion_order(self, backend):
+ run, _dataflow = _stat_run(backend)
+ while True:
+ before = list(run.fragments)
+ fragment = run.run_next(prefer=("category",))
+ if fragment is None:
+ break
+ assert run.fragments[:-1] == before, "nothing already in the list changes"
+ assert run.fragments[-1] is fragment
+ assert [list(f) for f in run.fragments] == [["category"], ["price"], ["qty"]]
+ assert len(run.ran) == 3 and len(set(run.ran)) == 3
+
+ def test_prefer_runs_the_units_that_name_those_columns_first(self, backend):
+ run, _dataflow = _stat_run(backend)
+ assert run.next_unit().columns == ("price",)
+ assert run.next_unit(prefer=("c",)).columns == ("category",), "a rewritten name works too"
+ assert run.next_unit(prefer=("nothing",)).columns == ("price",)
+
+ def test_prefer_reads_a_name_that_is_an_original_name_as_that_column(self, backend):
+ """"a" is the original name of the last column and the rewritten name
+ of the first. Read once, as the original name, it picks the last."""
+ run, _dataflow = _permuted_stat_run(backend)
+ assert [u.columns for u in run.units] == [("c",), ("b",), ("a",)]
+ assert run.next_unit(prefer=("a",)).columns == ("a",)
+ assert run.next_unit(prefer=("b",)).columns == ("b",)
+ assert run.next_unit(prefer=("c",)).columns == ("c",)
+
+ def test_prefer_in_the_rewritten_namespace_reads_a_client_s_names(self, backend):
+ """A client knows only the rewritten names: its "c" is the column
+ whose original name is "a"."""
+ run, _dataflow = _permuted_stat_run(backend)
+ assert run.next_unit(prefer=("c",), namespace="rewritten").columns == ("a",)
+ assert run.next_unit(prefer=("a",), namespace="rewritten").columns == ("c",)
+ assert run.next_unit(prefer=("zzz",), namespace="rewritten").columns == ("c",)
+
+ def test_run_next_runs_the_unit_prefer_picks_in_its_namespace(self, backend):
+ run, _dataflow = _permuted_stat_run(backend)
+ assert list(run.run_next(prefer=("c",), namespace="rewritten")) == ["a"]
+ assert list(run.run_next(prefer=("c",))) == ["c"]
+ assert run.ran == ["column:a", "column:c"]
+
+ def test_prefer_with_an_unknown_namespace_is_an_error(self, backend):
+ run, _dataflow = _permuted_stat_run(backend)
+ with pytest.raises(ValueError, match="namespace"):
+ run.next_unit(prefer=("c",), namespace="both")
+
+ def test_the_run_is_complete_when_no_unit_is_left(self, backend):
+ run, _dataflow = _stat_run(backend)
+ while run.run_next() is not None:
+ pass
+ assert (run.status, run.remaining) == ("complete", 0)
+ assert run.next_unit() is None and run.run_next() is None
+ assert len(run.fragments) == 3, "asking again runs nothing"
+
+ def test_the_accumulator_holds_what_the_fragments_hold(self, backend):
+ run, _dataflow = _stat_run(backend)
+ while run.run_next() is not None:
+ pass
+ assert merge_fragments(run.fragments) == run.acc.sd()
+
+ def test_raw_sd_is_the_full_stats_summary_sd(self, backend):
+ run, dataflow = _stat_run(backend)
+ while run.run_next() is not None:
+ pass
+ assert _as_json(run.raw_sd()) == _as_json(dataflow.summary_sd)
+
+ def test_cursors_read_one_list_each_at_its_own_pace(self, backend):
+ run, _dataflow = _stat_run(backend)
+ a, b = StatCursor(), StatCursor()
+ run.run_next()
+ run.run_next()
+ first_a = a.take(run)
+ assert len(first_a) == 2 and a.take(run) == [] and a.caught_up(run)
+ run.run_next()
+ assert not a.caught_up(run) and b.position == 0, "one cursor's reads move no other"
+ all_b = b.take(run)
+ rest_a = a.take(run)
+ assert (len(all_b), len(rest_a)) == (3, 1)
+ assert merge_fragments(first_a + rest_a) == merge_fragments(all_b)
+ assert len(set(run.ran)) == 3, "no unit ran twice"
+
+ def test_a_cursor_starts_again_on_another_run(self, backend):
+ first, _dataflow = _stat_run(backend, gen=1)
+ second, _dataflow = _stat_run(backend, gen=2)
+ first.run_next()
+ second.run_next()
+ second.run_next()
+ cursor = StatCursor()
+ assert len(cursor.take(first)) == 1
+ assert len(cursor.take(second)) == 2, "a position in one run means nothing in another"
+
+ def test_a_run_has_no_thread_timer_or_callback(self, backend, monkeypatch):
+ scheduled = []
+ monkeypatch.setattr(threading.Thread, "start", lambda self, *a, **k: scheduled.append("thread"))
+ for name in ("add_callback", "call_later", "call_at", "add_timeout"):
+ monkeypatch.setattr(IOLoop, name, lambda self, *a, _n=name, **k: scheduled.append(_n))
+ threads_before = threading.active_count()
+ run, _dataflow = _stat_run(backend)
+ while run.run_next() is not None:
+ pass
+ assert scheduled == [] and threading.active_count() == threads_before
+ assert not [name for name, value in vars(run).items()
+ if isinstance(value, (threading.Thread, threading.Timer)) or inspect.isroutine(value)]
+
+ def test_a_unit_that_raises_fails_the_run(self):
+
+ class _FailingStats:
+ state = StatState(data=None)
+
+ def plan(self, state):
+ return [StatUnit("one", ("x",), "column"), StatUnit("two", ("y",), "column")]
+
+ def new_accumulator(self, state):
+ return StatAccumulator(state, columns=["x", "y"])
+
+ def run(self, unit, acc):
+ if unit.id == "two":
+ raise RuntimeError("boom")
+ return {"x": {"v": 1}}
+
+ run = StatRun(1, "raw", _FailingStats())
+ run.run_next()
+ with pytest.raises(RuntimeError, match="boom"):
+ run.run_next()
+ assert run.status == "error" and isinstance(run.error, RuntimeError)
+ assert run.run_next() is None, "a failed run is not retried"
+ assert run.fragments == [{"x": {"v": 1}}]
+
+
+class _FakeClock:
+ """A clock a test moves by hand, so a budget can be spent without waiting."""
+
+ def __init__(self):
+ self.now = 0.0
+
+ def __call__(self):
+ return self.now
+
+
+class _CostedStats:
+ """A stats class whose units run for real and cost what the test says on a
+ fake clock: ``cost`` is the default and ``costs`` maps a unit id to its own."""
+
+ def __init__(self, stats, clock, cost, costs=None):
+ self._stats, self._clock, self._cost, self._costs = stats, clock, cost, costs or {}
+ self.state = stats.state
+
+ def plan(self, state):
+ return self._stats.plan(state)
+
+ def new_accumulator(self, state):
+ return self._stats.new_accumulator(state)
+
+ def run(self, unit, acc):
+ fragment = self._stats.run(unit, acc)
+ self._clock.now += self._costs.get(unit.id, self._cost)
+ return fragment
+
+
+def _costed_run(backend, clock, cost, costs=None, gen=4):
+ run, dataflow = _stat_run(backend, gen)
+ run.stats = _CostedStats(run.stats, clock, cost, costs)
+ return run, dataflow
+
+
+def _without_memory_usage(sd):
+ """``sd`` without the ``memory_usage`` stats, which pandas reads off the
+ index and so depend on what ran on the frame before."""
+ return {col: {k: v for k, v in stats.items() if not k.endswith("memory_usage")} for col, stats in sd.items()}
+
+
+def _stat_rows(payload):
+ """The decoded ``all_stats`` rows of a payload, keyed by stat name."""
+ return {row["index"]: row for row in resolve_summary_stats_payload(payload)}
+
+
+class TestRunUnits:
+ """Running a ``StatRun``'s units for one request (rows-first s5): as many as
+ fit in the request's time budget, and at least one."""
+
+ def test_units_that_cost_little_run_together_within_the_budget(self, backend):
+ clock = _FakeClock()
+ run, _dataflow = _costed_run(backend, clock, cost=0.01)
+ assert stats_wire.run_units(run, 0.075, clock=clock) == 3
+ assert run.status == "complete" and run.remaining == 0
+
+ def test_a_request_stops_once_the_budget_is_spent(self, backend):
+ clock = _FakeClock()
+ run, _dataflow = _costed_run(backend, clock, cost=0.04)
+ assert stats_wire.run_units(run, 0.075, clock=clock) == 2
+ assert run.status == "pending" and run.remaining == 1
+
+ def test_a_unit_that_costs_more_than_the_budget_is_the_only_one_of_its_request(self, backend):
+ clock = _FakeClock()
+ run, _dataflow = _costed_run(backend, clock, cost=0.2)
+ assert [stats_wire.run_units(run, 0.075, clock=clock) for _ in range(3)] == [1, 1, 1]
+ assert run.status == "complete"
+
+ def test_a_cold_unit_after_warm_ones_ends_the_request(self, backend):
+ """A snapshot-cache hit costs milliseconds and a miss is a query: the
+ units that hit run together, and the first miss is the last of its
+ request."""
+ clock = _FakeClock()
+ run, _dataflow = _costed_run(backend, clock, cost=0.005, costs={"column:category": 0.5})
+ assert stats_wire.run_units(run, 0.075, prefer=("c",), clock=clock) == 1
+ assert run.ran == ["column:category"]
+ assert stats_wire.run_units(run, 0.075, clock=clock) == 2
+ assert run.status == "complete"
+
+ def test_one_unit_runs_whatever_the_budget(self, backend):
+ clock = _FakeClock()
+ run, _dataflow = _costed_run(backend, clock, cost=0.0)
+ assert stats_wire.run_units(run, 0, clock=clock) == 1
+ assert run.remaining == 2
+
+ def test_no_budget_runs_every_unit(self, backend):
+ clock = _FakeClock()
+ run, _dataflow = _costed_run(backend, clock, cost=10.0)
+ assert stats_wire.run_units(run, None, clock=clock) == 3
+ assert run.status == "complete"
+
+ def test_a_complete_run_runs_nothing(self, backend):
+ clock = _FakeClock()
+ run, _dataflow = _costed_run(backend, clock, cost=0.0)
+ stats_wire.run_units(run, None, clock=clock)
+ assert stats_wire.run_units(run, 0.075, clock=clock) == 0
+ assert len(run.fragments) == 3
+
+ def test_the_columns_hint_is_read_in_the_client_s_rewritten_names(self, backend):
+ """The grid knows only a, b, c: "c" is the third column, category."""
+ clock = _FakeClock()
+ run, _dataflow = _costed_run(backend, clock, cost=1.0)
+ stats_wire.run_units(run, 0, prefer=["c"], clock=clock)
+ stats_wire.run_units(run, 0, prefer=["b"], clock=clock)
+ assert run.ran == ["column:category", "column:qty"]
+
+ def test_a_unit_that_raises_fails_the_run_and_the_error_propagates(self, backend):
+ clock = _FakeClock()
+ run, _dataflow = _costed_run(backend, clock, cost=0.0)
+
+ def boom(unit, acc):
+ raise RuntimeError("unit failed")
+
+ run.stats.run = boom
+ with pytest.raises(RuntimeError, match="unit failed"):
+ stats_wire.run_units(run, 0.075, clock=clock)
+ assert run.status == "error"
+
+ def test_each_unit_is_a_stats_unit_span_on_the_bound_sink(self, backend):
+ clock = _FakeClock()
+ run, _dataflow = _costed_run(backend, clock, cost=0.0)
+ spans = []
+ with perf_log.telemetry_context("run-units", spans.append):
+ stats_wire.run_units(run, None, clock=clock, session_id="run-units")
+ units = [r for r in spans if r["name"] == "stats.unit"]
+ assert [r["attrs"]["unit"] for r in units] == run.ran
+ for record, unit in zip(units, run.units):
+ assert record["trace"] == "run-units"
+ attrs = record["attrs"]
+ assert (attrs["session"], attrs["stats_gen"]) == ("run-units", 4)
+ assert (attrs["phase"], attrs["cost"], attrs["columns"]) == (unit.phase, unit.cost, len(unit.columns))
+
+ def test_the_run_adds_up_the_time_its_units_took(self, backend):
+ run, _dataflow = _stat_run(backend)
+ assert run.elapsed_s == 0
+ run.run_next()
+ first = run.elapsed_s
+ assert first > 0
+ run.run_next()
+ assert run.elapsed_s > first
+
+ def test_a_clean_run_has_no_errors(self, backend):
+ run, _dataflow = _stat_run(backend)
+ stats_wire.run_units(run, None)
+ assert run.errs() == {}
+
+
+class TestPartialPayload:
+ """The ``all_stats`` payload of the fragments a client has not seen: the
+ stats of the state, assembled the way ``merged_sd`` is, for the columns
+ those fragments cover."""
+
+ def test_the_payload_of_every_fragment_is_the_dataflow_s_all_stats(self, backend):
+ run, dataflow = _stat_run(backend)
+ stats_wire.run_units(run, None)
+ payload = stats_wire.partial_payload(dataflow, run, run.fragments)
+ assert (payload["format"], payload["layout"]) == ("parquet_b64", "wide")
+ assert _stat_rows(payload) == _stat_rows(dataflow.df_data_dict["all_stats"])
+
+ def test_the_payload_holds_only_the_columns_the_fragments_cover(self, backend):
+ run, _dataflow = _stat_run(backend)
+ fragment = run.run_next(prefer=("category",))
+ payload = stats_wire.partial_payload(_dataflow, run, [fragment])
+ columns = {name for row in _stat_rows(payload).values() for name in row if name not in ("index", "level_0")}
+ assert columns == {"c"}, "category is the third column, rewritten to c"
+
+ def test_a_search_filter_keys_the_run_s_stats_as_filtered(self, backend):
+ """With a search active the run analyzes the filtered frame, so its
+ stats are the ``filtered_*`` keys of ``merged_sd`` and the bare keys
+ stay the unfiltered scope's."""
+ dataflow = _three_scope_dataflow(backend, _UNTIED_DATA)
+ run = StatRun(4, "raw", dataflow.build_stats(dataflow.processed_df, run=False))
+ stats_wire.run_units(run, None)
+ assert _as_json(_without_memory_usage(dataflow._assemble_merged_sd(run.raw_sd()))) == _as_json(
+ _without_memory_usage(dataflow.merged_sd))
+
+ def test_init_sd_overrides_win_over_the_run_s_stats_as_in_merged_sd(self, backend):
+ dataflow = _build_dataflow(backend, _frame(backend, _UNTIED_DATA),
+ init_sd={"qty": {"mean": 99.5, "from_init": 1}})
+ run = StatRun(4, "raw", dataflow.build_stats(dataflow.processed_df, run=False))
+ stats_wire.run_units(run, None)
+ assembled = dataflow._assemble_merged_sd(run.raw_sd())
+ assert _as_json(assembled) == _as_json(dataflow.merged_sd)
+ assert assembled["b"]["from_init"] == 1
+
+
+class TestAssembledMergedSd:
+ """``_assemble_merged_sd`` is the body of the ``merged_sd`` observer."""
+
+ def test_it_is_merged_sd_with_no_sd_standing_in(self, backend):
+ dataflow = _three_scope_dataflow(backend, _UNTIED_DATA)
+ assert _as_json(dataflow._assemble_merged_sd()) == _as_json(dataflow.merged_sd)
+
+ def test_a_running_sd_stands_in_for_the_scopes_that_share_the_filt_chain(self, backend):
+ dataflow = _build_dataflow(backend, _frame(backend, _UNTIED_DATA), stats_tier="schema")
+ full = _build_dataflow(backend, _frame(backend, _UNTIED_DATA))
+ assembled = dataflow._assemble_merged_sd(full.summary_sd)
+ assert _as_json(assembled) == _as_json(full.merged_sd)
+ assert "mean" not in dataflow.merged_sd["a"], "the dataflow itself is still at the schema tier"
+
+
+class TestHighlightedDisplayArgs:
+ def test_the_term_is_set_on_string_columns_of_a_copy(self, backend):
+ dataflow = _build_dataflow(backend, _frame(backend, _UNTIED_DATA))
+ original = json.dumps(dataflow.df_display_args, sort_keys=True, default=str)
+ highlighted = stats_wire.highlighted_display_args(dataflow.df_display_args, "ca")
+ assert json.dumps(dataflow.df_display_args, sort_keys=True, default=str) == original
+ phrases = [col["displayer_args"]["highlight_phrase"]
+ for col in highlighted["main"]["df_viewer_config"]["column_config"]
+ if col.get("displayer_args", {}).get("highlight_phrase")]
+ assert phrases == [["ca"]]
+
+ def test_an_empty_term_removes_the_highlight(self, backend):
+ dataflow = _build_dataflow(backend, _frame(backend, _UNTIED_DATA))
+ highlighted = stats_wire.highlighted_display_args(dataflow.df_display_args, "ca")
+ cleared = stats_wire.highlighted_display_args(highlighted, "")
+ assert json.dumps(cleared, sort_keys=True, default=str) == json.dumps(
+ dataflow.df_display_args, sort_keys=True, default=str)
+
+
+class TestDisplayArgsHash:
+ def test_equal_content_hashes_equal_whatever_the_key_order(self):
+ assert stats_wire.display_args_hash({"a": 1, "b": [1, 2]}) == stats_wire.display_args_hash({"b": [1, 2], "a": 1})
+
+ def test_a_changed_value_changes_the_hash(self):
+ assert stats_wire.display_args_hash({"a": 1}) != stats_wire.display_args_hash({"a": 2})
+
+ def test_nan_hashes_equal_to_nan(self):
+ assert stats_wire.display_args_hash({"a": float("nan")}) == stats_wire.display_args_hash({"a": float("nan")})
diff --git a/tests/unit/server/test_load_expr.py b/tests/unit/server/test_load_expr.py
index bdbb47db6..813955234 100644
--- a/tests/unit/server/test_load_expr.py
+++ b/tests/unit/server/test_load_expr.py
@@ -1,17 +1,23 @@
"""End-to-end tests for POST /load_expr — server load path for
XorqBuckarooInfiniteWidget over a xorq/ibis expression."""
+import dataclasses
+import datetime
import io
import json
import os
import shutil
import sys
import tempfile
+import time
+from contextlib import contextmanager
from pathlib import Path
+from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pandas as pd
import pyarrow.parquet as pq
import pytest
+import tornado.gen
import tornado.httpclient
import tornado.testing
import tornado.websocket
@@ -25,8 +31,20 @@
from xorq.common.utils.provenance_utils import read_parquet_provenance # noqa: E402
from xorq.expr.relations import CachedNode # noqa: E402
-from buckaroo.server import telemetry, xorq_loading # noqa: E402
+from buckaroo.dataflow.dataflow import assemble_merged_sd # noqa: E402
+from buckaroo.dataflow.sd_cache import split_chain_by_scope # noqa: E402
+from buckaroo.jlisp.lisp_utils import s as lisp_sym # noqa: E402
+from buckaroo.pluggable_analysis_framework.col_analysis import ColAnalysis # noqa: E402
+from buckaroo.pluggable_analysis_framework.stat_units import merge_fragments # noqa: E402
+from buckaroo.pluggable_analysis_framework.xorq_stat_pipeline import XorqStatPipeline # noqa: E402
+from buckaroo.serialization_utils import resolve_summary_stats_payload # noqa: E402
+from buckaroo.server import stats_wire, telemetry, xorq_loading # noqa: E402
from buckaroo.server.app import make_app as _make_app # noqa: E402
+from buckaroo.server.stat_run import StatRun # noqa: E402
+from buckaroo.server.stats_wire import start_stat_run # noqa: E402
+from buckaroo.server.websocket_handler import DataStreamHandler # noqa: E402
+from tests.unit.dataflow.scoped_summary_stats_test import ( # noqa: E402
+ _OverridingPostProcessing, _scope_inputs, _scope_sds_by_units)
pytestmark = pytest.mark.skipif(
sys.platform == "win32",
@@ -910,6 +928,1719 @@ def tracking_default_backend():
shutil.rmtree(builds_root, ignore_errors=True)
+def _stats_tier_expr():
+ """A 5-row table whose float column has a 1e9 maximum, so its estimated
+ column width depends on the min and max that only full stats supply."""
+ return xo.memtable({
+ "price": [12.5, 18.9, 7.4, 22.1, 1e9],
+ "qty": [1, 2, 1, 3, 2],
+ "category": ["a", "b", "a", "c", "b"]})
+
+
+def _build_dataflow(expr=None, **kwargs):
+ return xorq_loading.XorqServerDataflow(
+ _stats_tier_expr() if expr is None else expr, skip_main_serial=True, **kwargs)
+
+
+def _three_scope_dataflow(expr=None, **kwargs):
+ """A dataflow with a user op (clean scope) and a search (filt scope) active,
+ so raw, clean and filt each hold their own summary-stats cache entry."""
+ dataflow = _build_dataflow(expr, **kwargs)
+ dataflow.operations = [[lisp_sym("fillna"), {"symbol": "df"}, "qty", 0]]
+ dataflow.quick_command_args = {"search": ["a"]}
+ return dataflow
+
+
+def _spy_data_queries(monkeypatch):
+ """Record the outermost op of every materialisation (``execute`` and
+ ``to_pyarrow``), and every stat query XorqStatPipeline sends."""
+ from xorq.vendor.ibis.expr.types.core import Expr
+
+ from buckaroo.pluggable_analysis_framework.xorq_stat_pipeline import XorqStatPipeline
+ ops, stat_queries = [], []
+ original_execute, original_to_pyarrow = Expr.execute, Expr.to_pyarrow
+ original_stat_execute = XorqStatPipeline._execute
+
+ def spy_execute(self, *args, **kwargs):
+ ops.append(type(self.op()).__name__)
+ return original_execute(self, *args, **kwargs)
+
+ def spy_to_pyarrow(self, *args, **kwargs):
+ ops.append(type(self.op()).__name__)
+ return original_to_pyarrow(self, *args, **kwargs)
+
+ def spy_stat_execute(self, query):
+ stat_queries.append(type(query.op()).__name__)
+ return original_stat_execute(self, query)
+
+ monkeypatch.setattr(Expr, "execute", spy_execute)
+ monkeypatch.setattr(Expr, "to_pyarrow", spy_to_pyarrow)
+ monkeypatch.setattr(XorqStatPipeline, "_execute", spy_stat_execute)
+ return ops, stat_queries
+
+
+def _without_min_width(column_config):
+ return [{**cc, "ag_grid_specs": {k: v for k, v in cc["ag_grid_specs"].items() if k != "minWidth"}}
+ for cc in column_config]
+
+
+def _as_json(sd):
+ """Comparable form of an sd: json equates NaN with NaN where == would not."""
+ return json.dumps(sd, sort_keys=True, default=str)
+
+
+class _NoopPostProcessing(ColAnalysis):
+ provides_defaults = {}
+ post_processing_method = "noop_post"
+
+ @classmethod
+ def post_process_df(cls, expr):
+ return [expr, {}]
+
+
+class TestStatsTierSchema:
+ """``XorqServerDataflow(..., stats_tier="schema")`` (rows-first s1): the
+ dataflow publishes identity and typing for every column, with no data query
+ beyond the cached row count."""
+
+ def test_matches_full_stats_display_state(self):
+ expr = _stats_tier_expr()
+ full = _build_dataflow(expr)
+ schema = _build_dataflow(expr, stats_tier="schema")
+ assert schema.df_display_args.keys() == full.df_display_args.keys()
+ for name, full_arg in full.df_display_args.items():
+ schema_arg = schema.df_display_args[name]
+ assert schema_arg["data_key"] == full_arg["data_key"]
+ assert schema_arg["summary_stats_key"] == full_arg["summary_stats_key"]
+ full_cfg, schema_cfg = full_arg["df_viewer_config"], schema_arg["df_viewer_config"]
+ assert schema_cfg["pinned_rows"] == full_cfg["pinned_rows"]
+ assert (_without_min_width(schema_cfg["column_config"])
+ == _without_min_width(full_cfg["column_config"]))
+
+ def test_min_width_is_the_stats_derived_difference(self):
+ expr = _stats_tier_expr()
+ widths = {}
+ for tier in ("full", "schema"):
+ cfg = _build_dataflow(expr, stats_tier=tier).df_display_args["main"]["df_viewer_config"]
+ widths[tier] = {cc["header_name"]: cc["ag_grid_specs"]["minWidth"]
+ for cc in cfg["column_config"]}
+ # price's 1e9 maximum widens it under full stats; without min and max
+ # the estimate falls back to a one-digit value.
+ assert widths["schema"]["price"] < widths["full"]["price"]
+
+ def test_schema_sd_is_the_schema_keys_of_the_full_sd(self):
+ expr = _stats_tier_expr()
+ full_sd = _build_dataflow(expr).merged_sd
+ schema_sd = _build_dataflow(expr, stats_tier="schema").merged_sd
+ assert schema_sd.keys() == full_sd.keys()
+ for col, stats in schema_sd.items():
+ assert {"orig_col_name", "rewritten_col_name", "dtype", "_type", "is_numeric",
+ "is_integer", "is_float", "is_bool", "is_datetime", "is_string",
+ "length"} <= stats.keys()
+ assert "mean" not in stats and "histogram" not in stats
+ assert stats == {k: full_sd[col][k] for k in stats}
+
+ def test_issues_no_data_query_besides_the_cached_count(self, monkeypatch):
+ ops, stat_queries = _spy_data_queries(monkeypatch)
+ dataflow = _build_dataflow(stats_tier="schema")
+ dataflow.quick_command_args = {"search": ["a"]}
+ dataflow.add_analysis(_NoopPostProcessing)
+ assert stat_queries == []
+ assert ops and set(ops) == {"CountStar"}
+
+ def test_the_spy_sees_stat_queries_at_the_full_tier(self, monkeypatch):
+ ops, stat_queries = _spy_data_queries(monkeypatch)
+ _build_dataflow()
+ assert stat_queries, "the spy would not notice a stat query"
+ assert set(ops) - {"CountStar"}
+
+ def test_init_sd_hints_and_overrides_still_apply(self):
+ dataflow = _build_dataflow(
+ stats_tier="schema",
+ init_sd={"qty": {"displayer_args": {"displayer": "string", "max_length": 200}}},
+ column_config_overrides={
+ "category": {"displayer_args": {"displayer": "string", "max_length": 5000}}})
+ cfg = dataflow.df_display_args["main"]["df_viewer_config"]["column_config"]
+ by_header = {cc["header_name"]: cc for cc in cfg}
+ assert by_header["qty"]["displayer_args"]["max_length"] == 200
+ assert by_header["category"]["displayer_args"]["max_length"] == 5000
+
+ def test_skipped_column_keeps_init_sd_typing(self):
+ """A column in ``skip_stat_columns`` gets only name, dtype and length
+ from the pipeline, so its ``_type`` comes from ``init_sd``. The schema
+ tier must not layer the schema-derived typing keys over it."""
+ expr = _stats_tier_expr()
+ kwargs = {
+ "init_sd": {"qty": {"_type": "float", "mean": 1.8, "min": 1, "max": 3}},
+ "skip_stat_columns": ["qty"]}
+ full = _build_dataflow(expr, **kwargs)
+ schema = _build_dataflow(expr, stats_tier="schema", **kwargs)
+
+ def merged(dataflow, orig_col):
+ return next(v for v in dataflow.merged_sd.values() if v["orig_col_name"] == orig_col)
+
+ assert merged(full, "qty")["_type"] == "float"
+ assert merged(schema, "qty")["_type"] == "float"
+ assert not {"is_numeric", "is_integer", "is_float"} & merged(schema, "qty").keys()
+ assert merged(schema, "price")["_type"] == "float"
+ assert merged(schema, "price")["is_float"] is True
+ full_cfg = full.df_display_args["main"]["df_viewer_config"]["column_config"]
+ schema_cfg = schema.df_display_args["main"]["df_viewer_config"]["column_config"]
+ assert _without_min_width(schema_cfg) == _without_min_width(full_cfg)
+
+ def test_sorted_infinite_request_works(self):
+ dataflow = _build_dataflow(stats_tier="schema")
+ qty = next(k for k, v in dataflow.merged_sd.items() if v["orig_col_name"] == "qty")
+ resp, parquet = xorq_loading.handle_infinite_request_xorq(
+ dataflow, {"start": 0, "end": 5, "sourceName": "default",
+ "sort": qty, "sort_direction": "desc"})
+ assert "error_info" not in resp
+ assert resp["length"] == 5
+ assert pq.read_table(io.BytesIO(parquet)).column(qty).to_pylist() == [3, 2, 2, 1, 1]
+
+ def test_pending_state_writes_no_full_tier_cache_key(self):
+ dataflow = _three_scope_dataflow(stats_tier="schema")
+ chains = split_chain_by_scope(dataflow.operations)
+ full_keys = {dataflow._scope_cache_key(chain, tier="full") for chain in chains.values()}
+ assert len(full_keys) == 3, "raw, clean and filt must each have their own key"
+ assert dataflow.summary_stats_cache
+ assert not full_keys & dataflow.summary_stats_cache.keys()
+
+ def test_later_full_assignment_reaches_merged_sd_for_all_scopes(self):
+ expr = _stats_tier_expr()
+ full = _three_scope_dataflow(expr)
+ dataflow = _three_scope_dataflow(expr, stats_tier="schema")
+ assert "mean" not in dataflow.merged_sd["a"]
+ assert {"mean", "cleaned_mean", "filtered_mean"} <= full.merged_sd["a"].keys()
+
+ dataflow.stats_tier = "full"
+ dataflow.summary_sd = full.summary_sd
+
+ assert _as_json(dataflow.merged_sd) == _as_json(full.merged_sd)
+
+ def test_a_summary_sd_from_another_tier_is_not_cached_under_the_new_tier(self):
+ """An sd computed at the schema tier must not become the full tier's
+ entry because the tier flipped before the next cascade: a present key
+ is a hit, so it would never be repaired."""
+ dataflow = _three_scope_dataflow(stats_tier="schema")
+ stale = {col: {**stats, "stale": True} for col, stats in dataflow.summary_sd.items()}
+
+ dataflow.stats_tier = "full"
+ dataflow.summary_sd = stale
+
+ filt_sd = dataflow.summary_stats_cache[dataflow.filt_sd_key]
+ assert "mean" in filt_sd["a"] and "stale" not in filt_sd["a"]
+ assert "filtered_mean" in dataflow.merged_sd["a"]
+
+ def test_full_scope_sds_cached_first_are_used_without_a_stat_query(self, monkeypatch):
+ expr = _stats_tier_expr()
+ full = _three_scope_dataflow(expr)
+ dataflow = _three_scope_dataflow(expr, stats_tier="schema")
+ cache = dict(dataflow.summary_stats_cache)
+ for scope, chain in split_chain_by_scope(dataflow.operations).items():
+ cache[dataflow._scope_cache_key(chain, tier="full")] = full.summary_stats_cache[
+ getattr(full, f"{scope}_sd_key")]
+ dataflow.summary_stats_cache = cache
+ _ops, stat_queries = _spy_data_queries(monkeypatch)
+
+ dataflow.stats_tier = "full"
+ dataflow.summary_sd = full.summary_sd
+
+ assert stat_queries == []
+ assert _as_json(dataflow.merged_sd) == _as_json(full.merged_sd)
+
+
+class TestLoadExprStatsPolicy(tornado.testing.AsyncHTTPTestCase):
+ """``stats_tier`` and ``stats_delivery`` on POST /load_expr and
+ /reload_expr (rows-first s1). The pair is stored beside dataflow_kwargs,
+ replayed by /reload_expr, and kept out of the has_config tuple."""
+
+ def get_app(self):
+ return make_app()
+
+ def _session(self, sid):
+ return self._app.settings["sessions"].get(sid)
+
+ @tornado.testing.gen_test
+ async def test_defaults_are_full_and_inline(self):
+ builds_root = tempfile.mkdtemp()
+ try:
+ build_path = _build_expr_dir(builds_root)
+ resp = await _post(self.get_http_port(), "/load_expr",
+ {"session": "sp-default", "build_dir": build_path})
+ self.assertEqual(resp.code, 200)
+ session = self._session("sp-default")
+ self.assertEqual((session.stats_tier, session.stats_delivery), ("full", "inline"))
+ self.assertEqual(session.xorq_dataflow.stats_tier, "full")
+ self.assertIn("mean", session.xorq_dataflow.merged_sd["a"])
+ finally:
+ shutil.rmtree(builds_root, ignore_errors=True)
+
+ @tornado.testing.gen_test
+ async def test_schema_tier_builds_a_schema_dataflow(self):
+ builds_root = tempfile.mkdtemp()
+ try:
+ build_path = _build_expr_dir(builds_root)
+ resp = await _post(self.get_http_port(), "/load_expr",
+ {"session": "sp-schema", "build_dir": build_path, "stats_tier": "schema"})
+ self.assertEqual(resp.code, 200)
+ self.assertEqual(json.loads(resp.body)["rows"], 10)
+ session = self._session("sp-schema")
+ self.assertEqual((session.stats_tier, session.stats_delivery), ("schema", "inline"))
+ self.assertEqual(session.xorq_dataflow.stats_tier, "schema")
+ self.assertNotIn("mean", session.xorq_dataflow.merged_sd["a"])
+ finally:
+ shutil.rmtree(builds_root, ignore_errors=True)
+
+ @tornado.testing.gen_test
+ async def test_deferred_delivery_publishes_a_schema_dataflow_and_serves_rows(self):
+ builds_root = tempfile.mkdtemp()
+ try:
+ build_path = _build_expr_dir(builds_root)
+ resp = await _post(self.get_http_port(), "/load_expr",
+ {"session": "sp-deferred", "build_dir": build_path, "stats_delivery": "deferred"})
+ self.assertEqual(resp.code, 200)
+ session = self._session("sp-deferred")
+ # The session still targets full stats; the dataflow is built at
+ # the schema tier until a later phase delivers them.
+ self.assertEqual((session.stats_tier, session.stats_delivery), ("full", "deferred"))
+ self.assertEqual(session.xorq_dataflow.stats_tier, "schema")
+
+ ws = await tornado.websocket.websocket_connect(
+ f"ws://localhost:{self.get_http_port()}/ws/sp-deferred")
+ initial = json.loads(await ws.read_message())
+ self.assertEqual(initial["type"], "initial_state")
+ self.assertEqual(initial["df_meta"]["total_rows"], 10)
+ ws.write_message(json.dumps({"type": "infinite_request",
+ "payload_args": {"start": 0, "end": 10, "sourceName": "default", "origEnd": 10}}))
+ rows = json.loads(await ws.read_message())
+ self.assertEqual(rows["length"], 10)
+ self.assertNotIn("error_info", rows)
+ table = pq.read_table(io.BytesIO(await ws.read_message()))
+ self.assertEqual(table.num_rows, 10)
+ ws.close()
+ finally:
+ shutil.rmtree(builds_root, ignore_errors=True)
+
+ @tornado.testing.gen_test
+ async def test_deferred_delivery_runs_no_stat_query(self):
+ from buckaroo.pluggable_analysis_framework.xorq_stat_pipeline import XorqStatPipeline
+ builds_root = tempfile.mkdtemp()
+ stat_queries = []
+ original = XorqStatPipeline._execute
+
+ def spy(pipeline, query):
+ stat_queries.append(query)
+ return original(pipeline, query)
+
+ try:
+ build_path = _build_expr_dir(builds_root)
+ with patch.object(XorqStatPipeline, "_execute", spy):
+ resp = await _post(self.get_http_port(), "/load_expr",
+ {"session": "sp-deferred-q", "build_dir": build_path,
+ "stats_delivery": "deferred"})
+ self.assertEqual(resp.code, 200)
+ self.assertEqual(stat_queries, [])
+ finally:
+ shutil.rmtree(builds_root, ignore_errors=True)
+
+ @tornado.testing.gen_test
+ async def test_invalid_values_are_a_400(self):
+ builds_root = tempfile.mkdtemp()
+ try:
+ build_path = _build_expr_dir(builds_root)
+ for field, code in (("stats_tier", "invalid_stats_tier"),
+ ("stats_delivery", "invalid_stats_delivery")):
+ resp = await _post(self.get_http_port(), "/load_expr",
+ {"session": "sp-bad", "build_dir": build_path, field: "sometimes"})
+ self.assertEqual(resp.code, 400)
+ self.assertEqual(json.loads(resp.body)["error_code"], code)
+ self.assertIsNone(self._session("sp-bad"))
+ finally:
+ shutil.rmtree(builds_root, ignore_errors=True)
+
+ @tornado.testing.gen_test
+ async def test_warm_repost_with_an_unchanged_pair_short_circuits(self):
+ builds_root = tempfile.mkdtemp()
+ try:
+ build_path = _build_expr_dir(builds_root)
+ body = {"session": "sp-warm", "build_dir": build_path,
+ "stats_tier": "schema", "stats_delivery": "deferred"}
+ self.assertEqual((await _post(self.get_http_port(), "/load_expr", body)).code, 200)
+ with patch.object(xorq_loading, "load_expr_build_dir",
+ side_effect=AssertionError("an unchanged pair must take the warm exit")):
+ same = await _post(self.get_http_port(), "/load_expr", body)
+ omitted = await _post(self.get_http_port(), "/load_expr",
+ {"session": "sp-warm", "build_dir": build_path})
+ self.assertEqual(same.code, 200)
+ self.assertEqual(omitted.code, 200, "omitting the pair keeps the session's")
+ session = self._session("sp-warm")
+ self.assertEqual((session.stats_tier, session.stats_delivery), ("schema", "deferred"))
+ finally:
+ shutil.rmtree(builds_root, ignore_errors=True)
+
+ @tornado.testing.gen_test
+ async def test_the_default_pair_sent_on_every_post_does_not_defeat_the_warm_exit(self):
+ """has_config tests truthiness, so a host that always sends the pair
+ would rebuild on every POST if the fields were in it (#944)."""
+ builds_root = tempfile.mkdtemp()
+ try:
+ build_path = _build_expr_dir(builds_root)
+ body = {"session": "sp-warm-default", "build_dir": build_path,
+ "stats_tier": "full", "stats_delivery": "inline"}
+ self.assertEqual((await _post(self.get_http_port(), "/load_expr", body)).code, 200)
+ with patch.object(xorq_loading, "load_expr_build_dir",
+ side_effect=AssertionError("the warm exit must not see the pair as config")):
+ resp = await _post(self.get_http_port(), "/load_expr", body)
+ self.assertEqual(resp.code, 200)
+ finally:
+ shutil.rmtree(builds_root, ignore_errors=True)
+
+ @tornado.testing.gen_test
+ async def test_warm_repost_with_a_changed_pair_rebuilds(self):
+ builds_root = tempfile.mkdtemp()
+ try:
+ build_path = _build_expr_dir(builds_root)
+ sid = "sp-changed"
+ original = xorq_loading.load_expr_build_dir
+ calls = []
+
+ def counting_loader(bd, **kwargs):
+ calls.append(bd)
+ return original(bd, **kwargs)
+
+ with patch.object(xorq_loading, "load_expr_build_dir", side_effect=counting_loader):
+ await _post(self.get_http_port(), "/load_expr",
+ {"session": sid, "build_dir": build_path})
+ self.assertEqual(len(calls), 1)
+ resp = await _post(self.get_http_port(), "/load_expr",
+ {"session": sid, "build_dir": build_path, "stats_delivery": "deferred"})
+ self.assertEqual(resp.code, 200)
+ self.assertEqual(len(calls), 2, "a changed delivery must rebuild")
+ self.assertEqual(self._session(sid).xorq_dataflow.stats_tier, "schema")
+ resp = await _post(self.get_http_port(), "/load_expr",
+ {"session": sid, "build_dir": build_path,
+ "stats_tier": "schema", "stats_delivery": "deferred"})
+ self.assertEqual(len(calls), 3, "a changed tier must rebuild")
+ resp = await _post(self.get_http_port(), "/load_expr",
+ {"session": sid, "build_dir": build_path,
+ "stats_tier": "full", "stats_delivery": "inline"})
+ self.assertEqual(len(calls), 4, "returning to the defaults must rebuild")
+ session = self._session(sid)
+ self.assertEqual((session.stats_tier, session.stats_delivery), ("full", "inline"))
+ self.assertEqual(session.xorq_dataflow.stats_tier, "full")
+ self.assertIn("mean", session.xorq_dataflow.merged_sd["a"])
+ finally:
+ shutil.rmtree(builds_root, ignore_errors=True)
+
+ @tornado.testing.gen_test
+ async def test_reload_expr_replays_the_stored_pair(self):
+ builds_root = tempfile.mkdtemp()
+ project_root = tempfile.mkdtemp()
+ try:
+ build_path = _build_expr_dir(builds_root)
+ sid = "sp-reload"
+ await _post(self.get_http_port(), "/load_expr",
+ {"session": sid, "build_dir": build_path, "project_root": project_root,
+ "stats_delivery": "deferred"})
+ before = self._session(sid).xorq_dataflow
+ resp = await _post(self.get_http_port(), f"/reload_expr/{sid}", {})
+ self.assertEqual(resp.code, 200)
+ session = self._session(sid)
+ self.assertIsNot(session.xorq_dataflow, before)
+ self.assertEqual(session.xorq_dataflow.stats_tier, "schema")
+ self.assertEqual((session.stats_tier, session.stats_delivery), ("full", "deferred"))
+ finally:
+ shutil.rmtree(builds_root, ignore_errors=True)
+ shutil.rmtree(project_root, ignore_errors=True)
+
+ @tornado.testing.gen_test
+ async def test_reload_expr_accepts_a_new_pair_and_stores_it(self):
+ builds_root = tempfile.mkdtemp()
+ project_root = tempfile.mkdtemp()
+ try:
+ build_path = _build_expr_dir(builds_root)
+ sid = "sp-reload-new"
+ await _post(self.get_http_port(), "/load_expr",
+ {"session": sid, "build_dir": build_path, "project_root": project_root,
+ "stats_delivery": "deferred"})
+ resp = await _post(self.get_http_port(), f"/reload_expr/{sid}",
+ {"stats_delivery": "inline"})
+ self.assertEqual(resp.code, 200)
+ session = self._session(sid)
+ self.assertEqual((session.stats_tier, session.stats_delivery), ("full", "inline"))
+ self.assertEqual(session.xorq_dataflow.stats_tier, "full")
+ bad = await _post(self.get_http_port(), f"/reload_expr/{sid}",
+ {"stats_tier": "sometimes"})
+ self.assertEqual(bad.code, 400)
+ self.assertEqual(json.loads(bad.body)["error_code"], "invalid_stats_tier")
+ self.assertEqual((session.stats_tier, session.stats_delivery), ("full", "inline"))
+ finally:
+ shutil.rmtree(builds_root, ignore_errors=True)
+ shutil.rmtree(project_root, ignore_errors=True)
+
+
+def _build_stats_wire_dir(builds_root):
+ """Build the ``_stats_tier_expr`` table to ``builds_root``. Its float column
+ has a 1e9 maximum, so full stats change that column's ``minWidth`` and a
+ message built from the schema tier differs from a complete one there."""
+ expr = xo.memtable({
+ "price": [12.5, 18.9, 7.4, 22.1, 1e9],
+ "qty": [1, 2, 1, 3, 2],
+ "category": ["a", "b", "a", "c", "b"]}, name="t")
+ return str(xo.build_expr(expr, builds_dir=builds_root))
+
+
+def _state_change(**changes):
+ new_state = {"post_processing": "", "cleaning_method": "", "quick_command_args": {},
+ "df_display": "main", "show_commands": False, "sampled": False, "search_string": ""}
+ return json.dumps({"type": "buckaroo_state_change", "new_state": {**new_state, **changes}})
+
+
+def _stats_request(stats_gen, **fields):
+ return json.dumps({"type": "stats_request", "stats_gen": stats_gen, "scope": "raw", **fields})
+
+
+async def _read_json(ws, timeout=3.0):
+ """The next frame on ``ws``, decoded. Raises AssertionError, not a hang,
+ when none arrives."""
+ try:
+ frame = await tornado.gen.with_timeout(
+ datetime.timedelta(seconds=timeout), ws.read_message())
+ except tornado.gen.TimeoutError:
+ raise AssertionError(f"no frame within {timeout}s") from None
+ assert frame is not None, "the connection closed"
+ return json.loads(frame)
+
+
+def _rows_by_stat(payload):
+ """The decoded ``all_stats`` rows of a payload, keyed by stat name."""
+ return {row["index"]: row for row in resolve_summary_stats_payload(payload)}
+
+
+def _comparable(frame):
+ """An ``initial_state`` frame as a JSON string, for comparing a legacy
+ client's frame with what an inline session sends: ``all_stats`` decoded
+ (its parquet bytes follow column order) and ``df_meta.stats`` dropped."""
+ frame = json.loads(json.dumps(frame))
+ frame["df_data_dict"]["all_stats"] = _rows_by_stat(frame["df_data_dict"]["all_stats"])
+ frame["df_meta"].pop("stats", None)
+ return _as_json(frame)
+
+
+@contextmanager
+def _count_stat_queries():
+ """Record every query ``XorqStatPipeline`` sends while the block runs."""
+ queries = []
+ original = XorqStatPipeline._execute
+
+ def spy(pipeline, query):
+ queries.append(query)
+ return original(pipeline, query)
+
+ with patch.object(XorqStatPipeline, "_execute", spy):
+ yield queries
+
+
+def _merge_stat_payloads(payloads):
+ """What a client holds after key-merging ``stats_update`` payloads into
+ ``all_stats``, as ``StatsChannel`` does: a cell a payload carries replaces
+ the held one, and a null (the wide pivot's fill for a stat a column did not
+ carry) never replaces a value."""
+ merged = {}
+ for payload in payloads:
+ for stat, row in _rows_by_stat(payload).items():
+ cells = merged.setdefault(stat, {})
+ for column, value in row.items():
+ if column in ("index", "level_0") or (value is None and cells.get(column) is not None):
+ continue
+ cells[column] = value
+ return merged
+
+
+class _SlowStats:
+ """A stats class whose units run for real, each after ``delay`` seconds, as
+ a slow query would. ``fail_on`` names a unit that raises instead."""
+
+ def __init__(self, stats, delay=0.0, fail_on=None):
+ self._stats, self._delay, self._fail_on = stats, delay, fail_on
+ self.state = stats.state
+
+ def plan(self, state):
+ return self._stats.plan(state)
+
+ def new_accumulator(self, state):
+ return self._stats.new_accumulator(state)
+
+ def run(self, unit, acc):
+ time.sleep(self._delay)
+ if unit.id == self._fail_on:
+ raise RuntimeError(f"{unit.id} failed")
+ return self._stats.run(unit, acc)
+
+
+class TestStatsWire(tornado.testing.AsyncHTTPTestCase):
+ """``stats_request``, ``stats_update`` and ``stats_aborted`` on a deferred
+ ``/load_expr`` session, the ``stats_gen`` counter and ``df_meta.stats``, and
+ the per-connection capability (``?caps=stats_update``) that keeps a client
+ without it on complete messages (rows-first s3)."""
+
+ def get_app(self):
+ return make_app()
+
+ def setUp(self):
+ super().setUp()
+ self.builds_root = tempfile.mkdtemp()
+ self.project_root = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, self.builds_root, ignore_errors=True)
+ self.addCleanup(shutil.rmtree, self.project_root, ignore_errors=True)
+ pp_dir = os.path.join(self.project_root, "post_processing")
+ os.makedirs(pp_dir)
+ with open(os.path.join(pp_dir, "first_three.py"), "w") as f:
+ f.write("def process(expr):\n return expr.limit(3)\n")
+ self.build_path = _build_stats_wire_dir(self.builds_root)
+ self.clients = []
+
+ def tearDown(self):
+ for ws in self.clients:
+ ws.close()
+ super().tearDown()
+
+ def _session(self, sid):
+ return self._app.settings["sessions"].get(sid)
+
+ async def _load(self, sid, **body):
+ resp = await _post(self.get_http_port(), "/load_expr",
+ {"session": sid, "build_dir": self.build_path, "project_root": self.project_root, **body})
+ self.assertEqual(resp.code, 200, resp.body)
+
+ async def _connect(self, sid, caps=None):
+ """Open a WebSocket, with ``caps`` as ``?caps=``. Returns it with its
+ first ``initial_state``."""
+ suffix = f"?caps={caps}" if caps else ""
+ ws = await tornado.websocket.websocket_connect(
+ f"ws://localhost:{self.get_http_port()}/ws/{sid}{suffix}")
+ self.clients.append(ws)
+ return ws, await _read_json(ws)
+
+ def _stats(self, frame):
+ stats = frame["df_meta"].get("stats")
+ self.assertIsNotNone(stats, "initial_state carries no df_meta.stats")
+ return stats
+
+ async def _inline_frame(self, sid, **changes):
+ """What an inline session of the same build sends a client: the complete
+ message every complete message here must match. ``changes`` are sent as
+ a state change first, and the broadcast that follows is returned."""
+ await self._load(sid)
+ ws, frame = await self._connect(sid)
+ if changes:
+ ws.write_message(_state_change(**changes))
+ frame = await _read_json(ws)
+ return frame
+
+ def _assert_pending(self, frame, gen):
+ stats = self._stats(frame)
+ self.assertEqual((stats["status"], stats["tier"], stats["gen"]), ("pending", "schema", gen))
+ self.assertEqual(list(_rows_by_stat(frame["df_data_dict"]["all_stats"])), ["dtype"],
+ "a pending frame must carry the schema tier only")
+
+ def _assert_complete(self, frame, gen, inline_frame):
+ stats = self._stats(frame)
+ self.assertEqual((stats["status"], stats["tier"], stats["gen"]), ("complete", "full", gen))
+ self.assertIn("histogram_bins", _rows_by_stat(frame["df_data_dict"]["all_stats"]))
+ self.assertEqual(_comparable(frame), _comparable(inline_frame))
+
+ async def _pair(self, sid):
+ """A caps client, then a legacy client, on a deferred session. The
+ legacy client's connect completes the stats, so a later push is the
+ first thing to put the session back to pending."""
+ await self._load(sid, stats_delivery="deferred")
+ a, a_open = await self._connect(sid, caps="stats_update")
+ b, b_open = await self._connect(sid)
+ return a, a_open, b, b_open
+
+ @tornado.testing.gen_test
+ async def test_stats_request_returns_a_stats_update_that_completes_the_stats(self):
+ await self._load("sw-complete", stats_delivery="deferred")
+ inline = await self._inline_frame("sw-complete-inline")
+ ws, first = await self._connect("sw-complete", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ self._assert_pending(first, gen)
+ self.assertEqual(first["protocol_version"], 1)
+
+ ws.write_message(_stats_request(gen))
+ update = await _read_json(ws)
+
+ self.assertEqual(update["type"], "stats_update")
+ self.assertEqual(update["stats_gen"], gen)
+ self.assertEqual((update["scope"], update["tier"], update["final"]), ("raw", "full", True))
+ self.assertEqual((update["payload"]["format"], update["payload"]["layout"]),
+ ("parquet_b64", "wide"), "the payload must be inline, so binary pairing stays single-slot")
+ self.assertEqual(_rows_by_stat(update["payload"]),
+ _rows_by_stat(inline["df_data_dict"]["all_stats"]))
+ self.assertIsInstance(update["elapsed_ms"], (int, float))
+
+ @tornado.testing.gen_test
+ async def test_rows_are_served_before_and_after_the_stats_request(self):
+ await self._load("sw-rows", stats_delivery="deferred")
+ ws, first = await self._connect("sw-rows", caps="stats_update")
+ window = {"start": 0, "end": 5, "sourceName": "default", "origEnd": 5}
+ ws.write_message(json.dumps({"type": "infinite_request", "payload_args": window}))
+ self.assertEqual((await _read_json(ws))["length"], 5)
+ pq.read_table(io.BytesIO(await ws.read_message()))
+ ws.write_message(_stats_request(self._stats(first)["gen"]))
+ self.assertEqual((await _read_json(ws))["type"], "stats_update")
+ ws.write_message(json.dumps({"type": "infinite_request", "payload_args": window}))
+ self.assertEqual((await _read_json(ws))["type"], "infinite_resp",
+ "a stats_update must leave no stray binary frame in the stream")
+
+ @tornado.testing.gen_test
+ async def test_a_stale_stats_gen_gets_stats_aborted_and_runs_nothing(self):
+ await self._load("sw-stale", stats_delivery="deferred")
+ ws, first = await self._connect("sw-stale", caps="stats_update")
+ gen = self._stats(first)["gen"]
+
+ with _count_stat_queries() as queries:
+ ws.write_message(_stats_request(gen - 1))
+ aborted = await _read_json(ws)
+ self.assertEqual(aborted["type"], "stats_aborted")
+ self.assertEqual((aborted["stats_gen"], aborted["current_gen"], aborted["reason"]),
+ (gen - 1, gen, "stale"))
+ self.assertEqual(queries, [], "a stale request must run no stat query")
+
+ ws.write_message(_stats_request(gen))
+ self.assertEqual((await _read_json(ws))["type"], "stats_update",
+ "the stale request must not have consumed or failed the session's stats")
+
+ @tornado.testing.gen_test
+ async def test_an_unsupported_scope_gets_stats_aborted(self):
+ await self._load("sw-scope", stats_delivery="deferred")
+ ws, first = await self._connect("sw-scope", caps="stats_update")
+ ws.write_message(_stats_request(self._stats(first)["gen"], scope="filt"))
+ aborted = await _read_json(ws)
+ self.assertEqual((aborted["type"], aborted["reason"], aborted["scope"]),
+ ("stats_aborted", "unsupported_scope", "filt"))
+
+ @tornado.testing.gen_test
+ async def test_load_expr_and_reload_expr_bump_stats_gen(self):
+ await self._load("sw-gen", stats_delivery="deferred")
+ ws, first = await self._connect("sw-gen", caps="stats_update")
+ gen = self._stats(first)["gen"]
+
+ await self._load("sw-gen", stats_delivery="deferred", force_reload=True)
+ pushed = await _read_json(ws)
+ self.assertGreater(self._stats(pushed)["gen"], gen, "/load_expr must bump stats_gen")
+ gen = self._stats(pushed)["gen"]
+
+ resp = await _post(self.get_http_port(), "/reload_expr/sw-gen", {})
+ self.assertEqual(resp.code, 200, resp.body)
+ pushed = await _read_json(ws)
+ self.assertGreater(self._stats(pushed)["gen"], gen, "/reload_expr must bump stats_gen")
+
+ @tornado.testing.gen_test
+ async def test_df_meta_stats_survives_a_dataflow_field_change(self):
+ await self._load("sw-change", stats_delivery="deferred")
+ ws, first = await self._connect("sw-change", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ ws.write_message(_stats_request(gen))
+ self.assertEqual((await _read_json(ws))["type"], "stats_update")
+
+ with _count_stat_queries() as queries:
+ ws.write_message(_state_change(quick_command_args={"search": ["a"]}))
+ changed = await _read_json(ws)
+ self._assert_pending(changed, gen + 1)
+ self.assertEqual(queries, [], "a dataflow-field change on a deferred session must run no stats")
+ # The dataflow rebuilt df_meta for the filtered state; its keys are still there.
+ self.assertEqual((changed["df_meta"]["total_rows"], changed["df_meta"]["filtered_rows"]), (5, 2))
+
+ ws.write_message(_stats_request(gen))
+ aborted = await _read_json(ws)
+ self.assertEqual((aborted["type"], aborted["current_gen"], aborted["reason"]),
+ ("stats_aborted", gen + 1, "stale"))
+ ws.write_message(_stats_request(gen + 1))
+ update = await _read_json(ws)
+ self.assertEqual((update["type"], update["stats_gen"], update["final"]),
+ ("stats_update", gen + 1, True))
+
+ @tornado.testing.gen_test
+ async def test_a_legacy_client_stays_complete_while_a_caps_client_gets_a_stats_free_frame(self):
+ sid = "sw-ab"
+ inline = await self._inline_frame("sw-ab-inline", post_processing="first_three")
+ await self._load(sid, stats_delivery="deferred")
+ a1, a1_open = await self._connect(sid, caps="other,stats_update")
+ a2, _ = await self._connect(sid, caps="stats_update")
+ # Connecting runs the missing stats for a legacy client, so the session
+ # is complete when B1 changes it.
+ b1, b1_open = await self._connect(sid, caps="unknown")
+ b2, _ = await self._connect(sid)
+ gen = self._stats(a1_open)["gen"]
+ self.assertEqual(self._stats(b1_open)["status"], "complete")
+
+ with _count_stat_queries() as queries:
+ b1.write_message(_state_change(post_processing="first_three"))
+ frames = {name: await _read_json(ws)
+ for name, ws in (("a1", a1), ("a2", a2), ("b1", b1), ("b2", b2))}
+ for name in ("a1", "a2"):
+ self._assert_pending(frames[name], gen + 1)
+ for name in ("b1", "b2"):
+ self._assert_complete(frames[name], gen + 1, inline)
+ ran = len(queries)
+ self.assertGreater(ran, 0, "B's frame must have run the stats it carries")
+
+ a1.write_message(_stats_request(gen + 1))
+ update = await _read_json(a1)
+ self.assertEqual((update["type"], update["stats_gen"]), ("stats_update", gen + 1))
+ self.assertEqual(_rows_by_stat(update["payload"]),
+ _rows_by_stat(frames["b1"]["df_data_dict"]["all_stats"]))
+ self.assertEqual(len(queries), ran,
+ "A's request must be answered from the stats B's frame computed")
+
+ @tornado.testing.gen_test
+ async def test_load_expr_push_keeps_a_legacy_client_complete(self):
+ sid = "sw-push-load-expr"
+ inline = await self._inline_frame("sw-push-load-expr-inline")
+ a, a_open, b, _ = await self._pair(sid)
+ gen = self._stats(a_open)["gen"]
+ await self._load(sid, stats_delivery="deferred", force_reload=True)
+ self._assert_pending(await _read_json(a), gen + 1)
+ self._assert_complete(await _read_json(b), gen + 1, inline)
+
+ @tornado.testing.gen_test
+ async def test_reload_expr_push_keeps_a_legacy_client_complete(self):
+ sid = "sw-push-reload"
+ inline = await self._inline_frame("sw-push-reload-inline")
+ a, a_open, b, _ = await self._pair(sid)
+ gen = self._stats(a_open)["gen"]
+ resp = await _post(self.get_http_port(), f"/reload_expr/{sid}", {})
+ self.assertEqual(resp.code, 200, resp.body)
+ self._assert_pending(await _read_json(a), gen + 1)
+ self._assert_complete(await _read_json(b), gen + 1, inline)
+
+ @tornado.testing.gen_test
+ async def test_load_push_leaves_both_clients_complete(self):
+ """/load swaps the session to pandas, which has no deferred stats: the
+ stored policy must not leave it looking pending."""
+ sid = "sw-push-load"
+ a, _, b, _ = await self._pair(sid)
+ csv_fd, csv_path = tempfile.mkstemp(suffix=".csv")
+ os.close(csv_fd)
+ try:
+ pd.DataFrame({"x": [1, 2, 3], "y": ["p", "q", "r"]}).to_csv(csv_path, index=False)
+ resp = await _post(self.get_http_port(), "/load",
+ {"session": sid, "path": csv_path, "mode": "buckaroo"})
+ self.assertEqual(resp.code, 200, resp.body)
+ finally:
+ os.unlink(csv_path)
+ frames = [await _read_json(a), await _read_json(b)]
+ for frame in frames:
+ self.assertNotIn("stats", frame["df_meta"])
+ self.assertIn("histogram_bins", _rows_by_stat(frame["df_data_dict"]["all_stats"]))
+ self.assertEqual(_comparable(frames[0]), _comparable(frames[1]))
+
+ @tornado.testing.gen_test
+ async def test_load_compare_push_leaves_both_clients_complete(self):
+ sid = "sw-push-compare"
+ a, _, b, _ = await self._pair(sid)
+ paths = []
+ try:
+ for frame in (pd.DataFrame({"id": [1, 2], "v": [10, 20]}),
+ pd.DataFrame({"id": [1, 3], "v": [10, 30]})):
+ fd, path = tempfile.mkstemp(suffix=".csv")
+ os.close(fd)
+ frame.to_csv(path, index=False)
+ paths.append(path)
+ resp = await _post(self.get_http_port(), "/load_compare",
+ {"session": sid, "path1": paths[0], "path2": paths[1], "join_columns": ["id"]})
+ self.assertEqual(resp.code, 200, resp.body)
+ finally:
+ for path in paths:
+ os.unlink(path)
+ frames = [await _read_json(a), await _read_json(b)]
+ for frame in frames:
+ self.assertNotIn("stats", frame["df_meta"])
+ self.assertEqual(_comparable(frames[0]), _comparable(frames[1]))
+
+ @tornado.testing.gen_test
+ async def test_highlight_overlay_is_complete_for_a_legacy_client_and_stats_free_for_a_caps_client(self):
+ sid = "sw-overlay"
+ await self._load(sid, stats_delivery="deferred")
+ a, a_open = await self._connect(sid, caps="stats_update")
+ gen = self._stats(a_open)["gen"]
+
+ a.write_message(_state_change(search_string="ca"))
+ a_overlay = await _read_json(a)
+ self._assert_pending(a_overlay, gen)
+
+ b, _ = await self._connect(sid)
+ b.write_message(_state_change(search_string="ca"))
+ b_overlay = await _read_json(b)
+ self.assertEqual(self._stats(b_overlay)["status"], "complete")
+ self.assertIn("histogram_bins", _rows_by_stat(b_overlay["df_data_dict"]["all_stats"]))
+ # The overlay's display config is the complete one with the highlight on top.
+ session_args = self._session(sid).df_display_args["main"]["df_viewer_config"]["column_config"]
+ by_col = {cc["col_name"]: cc for cc in b_overlay["df_display_args"]["main"]["df_viewer_config"]["column_config"]}
+ for expected in session_args:
+ self.assertEqual(by_col[expected["col_name"]]["ag_grid_specs"], expected["ag_grid_specs"])
+ highlighted = [cc for cc in by_col.values()
+ if cc.get("displayer_args", {}).get("highlight_phrase") == ["ca"]]
+ self.assertTrue(highlighted, "the overlay must still carry the highlight")
+
+ @tornado.testing.gen_test
+ async def test_overlay_for_a_legacy_client_is_built_after_its_stats_are_completed(self):
+ """Completing the stats replaces the session's display config, so an
+ overlay that copied it first would send a legacy client the schema
+ tier's. Every send completes a session that has a legacy client
+ connected, so only a direct call reaches a pending session here."""
+ await self._load("sw-overlay-order", stats_delivery="deferred")
+ session = self._session("sw-overlay-order")
+ sent: list = []
+ legacy = SimpleNamespace(search_string="ca", caps=frozenset(), session_id="sw-overlay-order",
+ write_message=sent.append)
+ self.assertEqual(getattr(session, "stats_status", None), "pending")
+
+ DataStreamHandler._send_highlight_overlay(legacy, session)
+
+ self.assertEqual(session.stats_status, "complete")
+ overlay = json.loads(sent[0])["df_display_args"]["main"]["df_viewer_config"]["column_config"]
+ complete = session.df_display_args["main"]["df_viewer_config"]["column_config"]
+ self.assertEqual({cc["col_name"]: cc["ag_grid_specs"] for cc in overlay},
+ {cc["col_name"]: cc["ag_grid_specs"] for cc in complete})
+
+ @tornado.testing.gen_test
+ async def test_a_client_connecting_after_completion_gets_the_complete_state(self):
+ inline = await self._inline_frame("sw-late-inline")
+ await self._load("sw-late", stats_delivery="deferred")
+ a, a_open = await self._connect("sw-late", caps="stats_update")
+ gen = self._stats(a_open)["gen"]
+ a.write_message(_stats_request(gen))
+ self.assertEqual((await _read_json(a))["type"], "stats_update")
+
+ with _count_stat_queries() as queries:
+ _, late = await self._connect("sw-late", caps="stats_update")
+ _, legacy = await self._connect("sw-late")
+ self._assert_complete(late, gen, inline)
+ self._assert_complete(legacy, gen, inline)
+ self.assertEqual(queries, [], "stats computed once must not be computed again for a later client")
+
+ @tornado.testing.gen_test
+ async def test_a_legacy_client_connecting_to_a_pending_session_completes_it(self):
+ inline = await self._inline_frame("sw-open-inline")
+ await self._load("sw-open", stats_delivery="deferred")
+ _, legacy = await self._connect("sw-open")
+ self._assert_complete(legacy, self._stats(legacy)["gen"], inline)
+
+ @tornado.testing.gen_test
+ async def test_a_session_targeting_the_schema_tier_is_not_computed(self):
+ await self._load("sw-schema", stats_tier="schema", stats_delivery="deferred")
+ ws, first = await self._connect("sw-schema", caps="stats_update")
+ stats = self._stats(first)
+ self.assertEqual((stats["status"], stats["tier"], stats["reason"]), ("not_computed", "schema", "host"))
+ ws.write_message(_stats_request(stats["gen"]))
+ aborted = await _read_json(ws)
+ self.assertEqual((aborted["type"], aborted["reason"]), ("stats_aborted", "not_requestable"))
+ # Nothing is missing relative to the schema tier, so a legacy client
+ # gets the schema tier, as it does from an inline schema session.
+ _, legacy = await self._connect("sw-schema")
+ self.assertEqual(list(_rows_by_stat(legacy["df_data_dict"]["all_stats"])), ["dtype"])
+ await self._load("sw-schema-inline", stats_tier="schema")
+ _, inline_schema = await self._connect("sw-schema-inline")
+ self.assertEqual(self._stats(inline_schema)["status"], "not_computed")
+
+ @tornado.testing.gen_test
+ async def test_a_failed_stats_run_reports_error_until_the_next_generation(self):
+ await self._load("sw-error", stats_delivery="deferred")
+ ws, first = await self._connect("sw-error", caps="stats_update")
+ gen = self._stats(first)["gen"]
+
+ with patch.object(xorq_loading.XorqServerDataflow, "_get_summary_sd",
+ side_effect=RuntimeError("stats query failed")):
+ ws.write_message(_stats_request(gen))
+ aborted = await _read_json(ws)
+ self.assertEqual((aborted["type"], aborted["stats_gen"], aborted["reason"]), ("stats_aborted", gen, "error"))
+
+ # The failure is the session's state for this generation: a client that
+ # connects now is told so, and a legacy client still gets its frame.
+ _, caps_frame = await self._connect("sw-error", caps="stats_update")
+ stats = self._stats(caps_frame)
+ self.assertEqual((stats["status"], stats["tier"], stats["gen"]), ("error", "schema", gen))
+ self.assertIn("reason", stats)
+ _, legacy = await self._connect("sw-error")
+ self.assertEqual(legacy["type"], "initial_state")
+ self.assertEqual(list(_rows_by_stat(legacy["df_data_dict"]["all_stats"])), ["dtype"])
+
+ with _count_stat_queries() as queries:
+ ws.write_message(_stats_request(gen))
+ self.assertEqual((await _read_json(ws))["reason"], "error")
+ self.assertEqual(queries, [], "a failed generation must not be retried by every request")
+
+ ws.write_message(_state_change(quick_command_args={"search": ["a"]}))
+ self._assert_pending(await _read_json(ws), gen + 1)
+ ws.write_message(_stats_request(gen + 1))
+ self.assertEqual((await _read_json(ws))["type"], "stats_update")
+
+ @tornado.testing.gen_test
+ async def test_stats_request_emits_a_stats_request_span(self):
+ captured: list = []
+ with patch.object(telemetry, "make_http_sink", lambda url, **kw: captured.append):
+ await self._load("sw-span", stats_delivery="deferred",
+ telemetry_url="http://companion.invalid/internal/telemetry")
+ ws, first = await self._connect("sw-span", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ ws.write_message(_stats_request(gen - 1))
+ await _read_json(ws)
+ ws.write_message(_stats_request(gen, columns=["price"]))
+ await _read_json(ws)
+
+ stale, served = [r for r in captured if r["name"] == "stats.request"]
+ self.assertEqual(served["trace"], "sw-span")
+ self.assertEqual((served["attrs"]["stats_gen"], served["attrs"]["scope"], served["attrs"]["tier"]),
+ (gen, "raw", "full"))
+ self.assertEqual((served["attrs"]["outcome"], served["attrs"]["columns"]), ("update", 1))
+ self.assertEqual((stale["attrs"]["outcome"], stale["attrs"]["stats_gen"]), ("stale", gen - 1))
+ self.assertIn("firstpull.stats_total", [r["name"] for r in captured])
+
+ @tornado.testing.gen_test
+ async def test_returning_to_a_completed_state_is_answered_from_the_cache(self):
+ await self._load("sw-revisit", stats_delivery="deferred")
+ ws, first = await self._connect("sw-revisit", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ ws.write_message(_stats_request(gen))
+ original = await _read_json(ws)
+ ws.write_message(_state_change(quick_command_args={"search": ["a"]}))
+ self._assert_pending(await _read_json(ws), gen + 1)
+ ws.write_message(_stats_request(gen + 1))
+ self.assertEqual((await _read_json(ws))["type"], "stats_update")
+
+ ws.write_message(_state_change(quick_command_args={}))
+ self._assert_pending(await _read_json(ws), gen + 2)
+ with _count_stat_queries() as queries:
+ ws.write_message(_stats_request(gen + 2))
+ again = await _read_json(ws)
+ self.assertEqual(queries, [], "the first state's stats are in summary_stats_cache")
+ self.assertEqual(_rows_by_stat(again["payload"]), _rows_by_stat(original["payload"]))
+
+ @tornado.testing.gen_test
+ async def test_completing_the_stats_keeps_component_config(self):
+ await self._load("sw-theme", stats_delivery="deferred", component_config={"className": "sw-themed"})
+ ws, first = await self._connect("sw-theme", caps="stats_update")
+ ws.write_message(_stats_request(self._stats(first)["gen"]))
+ self.assertEqual((await _read_json(ws))["type"], "stats_update")
+ _, late = await self._connect("sw-theme", caps="stats_update")
+ dvc = late["df_display_args"]["main"]["df_viewer_config"]
+ self.assertEqual(dvc["component_config"]["className"], "sw-themed")
+
+ @tornado.testing.gen_test
+ async def test_a_warm_load_expr_keeps_the_generation(self):
+ await self._load("sw-warm", stats_delivery="deferred")
+ _, first = await self._connect("sw-warm", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ await self._load("sw-warm", stats_delivery="deferred")
+ _, again = await self._connect("sw-warm", caps="stats_update")
+ self.assertEqual(self._stats(again)["gen"], gen, "a warm exit rebuilds nothing, so the generation stands")
+
+ @tornado.testing.gen_test
+ async def test_a_stats_request_with_no_data_loaded_is_aborted(self):
+ ws = await tornado.websocket.websocket_connect(
+ f"ws://localhost:{self.get_http_port()}/ws/sw-no-data?caps=stats_update")
+ self.clients.append(ws)
+ ws.write_message(_stats_request(1))
+ aborted = await _read_json(ws)
+ self.assertEqual((aborted["type"], aborted["reason"]), ("stats_aborted", "no_data"))
+
+ @tornado.testing.gen_test
+ async def test_inline_sessions_send_no_df_meta_stats(self):
+ """Default behaviour: a session with the default policy sends the
+ message it always has, and a client reads the absence as complete."""
+ await self._load("sw-inline")
+ _, frame = await self._connect("sw-inline", caps="stats_update")
+ self.assertNotIn("stats", frame["df_meta"])
+ self.assertIn("histogram_bins", _rows_by_stat(frame["df_data_dict"]["all_stats"]))
+
+
+ @tornado.testing.gen_test
+ async def test_a_stat_run_is_started_on_the_session_for_the_current_generation(self):
+ await self._load("sw-run", stats_delivery="deferred")
+ session = self._session("sw-run")
+ self.assertEqual(session.stat_runs, {})
+
+ with _count_stat_queries() as queries:
+ run = start_stat_run(session)
+ self.assertIs(start_stat_run(session), run, "one run per generation and scope")
+
+ self.assertEqual(queries, [], "planning a run runs no query")
+ self.assertEqual(run.key, (session.stats_gen, "raw"))
+ self.assertEqual(session.stat_runs, {run.key: run})
+ self.assertEqual(run.remaining, 4, "the scalar batch and one histogram unit for each of three columns")
+ self.assertEqual(run.units[0].id, "batch")
+
+ @tornado.testing.gen_test
+ async def test_a_run_ends_with_the_summary_sd_an_inline_session_computes(self):
+ await self._load("sw-run-sd", stats_delivery="deferred")
+ session = self._session("sw-run-sd")
+ run = start_stat_run(session)
+ while run.run_next() is not None:
+ pass
+ inline = xorq_loading.XorqServerDataflow(session.expr, skip_main_serial=True)
+ self.assertEqual(run.status, "complete")
+ self.assertEqual(_as_json(run.raw_sd()), _as_json(inline.summary_sd))
+
+ @tornado.testing.gen_test
+ async def test_a_dataflow_field_change_drops_the_run_and_a_search_term_change_keeps_it(self):
+ await self._load("sw-drop", stats_delivery="deferred")
+ ws, _first = await self._connect("sw-drop", caps="stats_update")
+ session = self._session("sw-drop")
+ run = start_stat_run(session)
+
+ ws.write_message(_state_change(search_string="alp"))
+ await _read_json(ws) # this client's highlight overlay
+ self.assertEqual(session.stat_runs, {run.key: run}, "a typed search term touches no stats")
+
+ ws.write_message(_state_change(post_processing="first_three"))
+ await _read_json(ws)
+ self.assertEqual(session.stat_runs, {})
+ self.assertEqual(session.stats_gen, run.stats_gen + 1)
+ again = start_stat_run(session)
+ self.assertEqual(again.key, (run.stats_gen + 1, "raw"))
+ self.assertIsNot(again, run)
+
+ @tornado.testing.gen_test
+ async def test_reload_expr_drops_the_run(self):
+ await self._load("sw-drop-reload", stats_delivery="deferred")
+ session = self._session("sw-drop-reload")
+ start_stat_run(session)
+ resp = await _post(self.get_http_port(), "/reload_expr/sw-drop-reload", {})
+ self.assertEqual(resp.code, 200, resp.body)
+ self.assertEqual(session.stat_runs, {})
+
+ @tornado.testing.gen_test
+ async def test_each_connection_keeps_its_own_cursor_into_the_run(self):
+ await self._load("sw-cursor", stats_delivery="deferred")
+ await self._connect("sw-cursor", caps="stats_update")
+ await self._connect("sw-cursor", caps="stats_update")
+ session = self._session("sw-cursor")
+ cursor_a, cursor_b = [handler.stats_cursor for handler in session.ws_clients]
+ self.assertIsNot(cursor_a, cursor_b)
+ self.assertEqual((cursor_a.position, cursor_b.position), (0, 0))
+
+ run = start_stat_run(session)
+ run.run_next()
+ run.run_next()
+ first = cursor_a.take(run)
+ self.assertEqual((len(first), cursor_b.position), (2, 0))
+ while run.run_next() is not None:
+ pass
+ rest, everything = cursor_a.take(run), cursor_b.take(run)
+
+ self.assertEqual(merge_fragments(first + rest), merge_fragments(everything))
+ self.assertEqual(len(set(run.ran)), len(run.units), "no unit ran twice")
+
+ def _budget(self, seconds):
+ """Set the time budget of an incremental request for the rest of the
+ test. Zero runs exactly one unit per request, whatever it costs."""
+ patcher = patch.object(stats_wire, "STATS_BUDGET_S", seconds, create=True)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ async def _pull_stats(self, ws, gen, limit=12, **fields):
+ """Send incremental ``stats_request``s on ``ws`` until the final reply
+ (or an abort) and return every reply."""
+ replies = []
+ for _ in range(limit):
+ ws.write_message(_stats_request(gen, incremental=True, **fields))
+ reply = await _read_json(ws)
+ replies.append(reply)
+ if reply["type"] != "stats_update" or reply["final"]:
+ break
+ return replies
+
+ def _wrapped_run(self, sid, **kwargs):
+ """Give the session a StatRun for its current generation whose units run
+ for real but through ``_SlowStats``."""
+ session = self._session(sid)
+ dataflow = session.xorq_dataflow
+ run = StatRun(session.stats_gen, "raw",
+ _SlowStats(dataflow.build_stats(dataflow.processed_df, run=False), **kwargs))
+ session.stat_runs[run.key] = run
+ return run
+
+ async def _infinite_window(self, ws, end=5):
+ ws.write_message(json.dumps({"type": "infinite_request",
+ "payload_args": {"start": 0, "end": end, "sourceName": "default", "origEnd": end}}))
+ resp = await _read_json(ws)
+ pq.read_table(io.BytesIO(await ws.read_message()))
+ return resp
+
+ @tornado.testing.gen_test
+ async def test_incremental_requests_run_one_unit_each_and_end_with_the_complete_state(self):
+ inline = await self._inline_frame("sw-inc-inline")
+ await self._load("sw-inc", stats_delivery="deferred")
+ ws, first = await self._connect("sw-inc", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ self._budget(0)
+
+ with _count_stat_queries() as queries:
+ replies = await self._pull_stats(ws, gen)
+
+ self.assertEqual([r["type"] for r in replies], ["stats_update"] * 4)
+ self.assertEqual([r["final"] for r in replies], [False, False, False, True])
+ self.assertEqual([r["remaining"] for r in replies], [3, 2, 1, 0])
+ self.assertEqual(len(queries), 4, "one query for each unit, none repeated")
+ for reply in replies:
+ self.assertEqual((reply["stats_gen"], reply["scope"], reply["tier"]), (gen, "raw", "full"))
+ self.assertIsInstance(reply["elapsed_ms"], (int, float))
+ self.assertNotIn("histogram", _rows_by_stat(replies[0]["payload"]), "the scalar batch comes first")
+ self.assertIn("histogram", _rows_by_stat(replies[1]["payload"]))
+ self.assertEqual(_as_json(_merge_stat_payloads(r["payload"] for r in replies)),
+ _as_json(_merge_stat_payloads([inline["df_data_dict"]["all_stats"]])))
+
+ @tornado.testing.gen_test
+ async def test_the_last_unit_assigns_the_stats_and_frees_the_run(self):
+ await self._load("sw-assign", stats_delivery="deferred")
+ ws, first = await self._connect("sw-assign", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ session = self._session("sw-assign")
+ dataflow = session.xorq_dataflow
+ key = dataflow._scope_cache_key(split_chain_by_scope(dataflow.operations)["filt"], tier="full")
+ self._budget(0)
+
+ ws.write_message(_stats_request(gen, incremental=True))
+ await _read_json(ws)
+ self.assertEqual(len(session.stat_runs[(gen, "raw")].fragments), 1)
+ self.assertEqual((session.stats_status, dataflow.stats_tier), ("pending", "schema"))
+ self.assertNotIn(key, dataflow.summary_stats_cache, "a partial run writes nothing into the dataflow")
+
+ await self._pull_stats(ws, gen)
+ self.assertEqual(session.stat_runs, {}, "the accumulator is freed with the run")
+ self.assertEqual((session.stats_status, dataflow.stats_tier), ("complete", "full"))
+ self.assertIn(key, dataflow.summary_stats_cache)
+ self.assertIn("histogram_bins", _rows_by_stat(session.df_data_dict["all_stats"]),
+ "the session snapshot is refreshed in the same step")
+ self.assertEqual(_as_json(dataflow.summary_sd), _as_json(dataflow.summary_stats_cache[key]))
+
+ @tornado.testing.gen_test
+ async def test_a_long_budget_runs_every_unit_in_one_request(self):
+ await self._load("sw-warm-budget", stats_delivery="deferred")
+ ws, first = await self._connect("sw-warm-budget", caps="stats_update")
+ self._budget(3600)
+ with _count_stat_queries() as queries:
+ replies = await self._pull_stats(ws, self._stats(first)["gen"])
+ self.assertEqual(len(replies), 1)
+ self.assertEqual((replies[0]["final"], replies[0]["remaining"]), (True, 0))
+ self.assertEqual(len(queries), 4)
+
+ @tornado.testing.gen_test
+ async def test_the_columns_hint_orders_the_units_of_a_request(self):
+ await self._load("sw-hint", stats_delivery="deferred")
+ ws, first = await self._connect("sw-hint", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ self._budget(0)
+ ws.write_message(_stats_request(gen, incremental=True, columns=["c"]))
+ await _read_json(ws)
+ ws.write_message(_stats_request(gen, incremental=True, columns=["c"]))
+ reply = await _read_json(ws)
+ run = self._session("sw-hint").stat_runs[(gen, "raw")]
+ self.assertEqual(run.ran, ["batch", "histogram:category"],
+ "the batch every histogram reads goes first, then the unit of the column named, c being category")
+ columns = {name for row in _rows_by_stat(reply["payload"]).values() for name in row
+ if name not in ("index", "level_0")}
+ self.assertEqual(columns, {"c"})
+
+ @tornado.testing.gen_test
+ async def test_a_stale_gen_mid_run_gets_stats_aborted_and_the_next_generation_starts_over(self):
+ await self._load("sw-mid-stale", stats_delivery="deferred")
+ ws, first = await self._connect("sw-mid-stale", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ self._budget(0)
+ ws.write_message(_stats_request(gen, incremental=True))
+ self.assertFalse((await _read_json(ws))["final"])
+
+ ws.write_message(_state_change(quick_command_args={"search": ["a"]}))
+ self._assert_pending(await _read_json(ws), gen + 1)
+ with _count_stat_queries() as queries:
+ ws.write_message(_stats_request(gen, incremental=True))
+ aborted = await _read_json(ws)
+ self.assertEqual((aborted["type"], aborted["reason"], aborted["current_gen"]),
+ ("stats_aborted", "stale", gen + 1))
+ self.assertEqual(queries, [], "a stale request runs no unit")
+
+ replies = await self._pull_stats(ws, gen + 1)
+ self.assertEqual([r["remaining"] for r in replies], [3, 2, 1, 0], "the new generation runs every unit again")
+
+ @tornado.testing.gen_test
+ async def test_client_b_opening_after_client_a_finished_gets_the_complete_all_stats(self):
+ inline = await self._inline_frame("sw-after-inline")
+ await self._load("sw-after", stats_delivery="deferred")
+ a, a_open = await self._connect("sw-after", caps="stats_update")
+ gen = self._stats(a_open)["gen"]
+ self._budget(0)
+ self.assertEqual(len(await self._pull_stats(a, gen)), 4)
+
+ with _count_stat_queries() as queries:
+ b, b_open = await self._connect("sw-after", caps="stats_update")
+ self._assert_complete(b_open, gen, inline)
+ self.assertEqual(queries, [], "the stats A's run produced are not computed again for B")
+
+ b.write_message(_stats_request(gen, incremental=True))
+ reply = await _read_json(b)
+ self.assertEqual((reply["type"], reply["final"], reply["remaining"]), ("stats_update", True, 0))
+ self.assertEqual(queries, [])
+
+ @tornado.testing.gen_test
+ async def test_two_interleaved_clients_each_end_with_full_stats_and_run_no_unit_twice(self):
+ inline = await self._inline_frame("sw-inter-inline")
+ await self._load("sw-inter", stats_delivery="deferred")
+ a, a_open = await self._connect("sw-inter", caps="stats_update")
+ b, _ = await self._connect("sw-inter", caps="stats_update")
+ gen = self._stats(a_open)["gen"]
+ self._budget(0)
+ received = {"a": [], "b": []}
+ ran = []
+
+ with _count_stat_queries() as queries:
+ for _ in range(4):
+ for name, ws in (("a", a), ("b", b)):
+ ws.write_message(_stats_request(gen, incremental=True))
+ received[name].append(await _read_json(ws))
+ ran.append(len(queries))
+
+ self.assertEqual(ran, [1, 1, 2, 2, 3, 3, 4, 4],
+ "each unit ran once, for whichever client asked first, and the other was caught up without a query")
+ full = _as_json(_merge_stat_payloads([inline["df_data_dict"]["all_stats"]]))
+ for name in ("a", "b"):
+ replies = received[name]
+ self.assertTrue(all(r["type"] == "stats_update" for r in replies))
+ self.assertTrue(replies[-1]["final"])
+ self.assertEqual(_as_json(_merge_stat_payloads(r["payload"] for r in replies)), full, name)
+
+ @tornado.testing.gen_test
+ async def test_the_final_reply_carries_the_rebuilt_display_args_when_its_hash_differs(self):
+ await self._load("sw-dargs", stats_delivery="deferred")
+ a, a_open = await self._connect("sw-dargs", caps="stats_update")
+ b, _ = await self._connect("sw-dargs", caps="stats_update")
+ gen = self._stats(a_open)["gen"]
+ session = self._session("sw-dargs")
+ self._budget(0)
+
+ replies = await self._pull_stats(a, gen)
+ self.assertEqual(len(replies), 4)
+ for reply in replies[:-1]:
+ self.assertNotIn("df_display_args", reply)
+ rebuilt = replies[-1]["df_display_args"]
+ self.assertEqual(_as_json(rebuilt), _as_json(session.df_display_args))
+ self.assertNotEqual(_as_json(rebuilt), _as_json(a_open["df_display_args"]),
+ "full stats change the float column's minWidth")
+
+ # B asked for nothing while A ran, and still holds the schema tier's config.
+ b.write_message(_stats_request(gen, incremental=True))
+ late = await _read_json(b)
+ self.assertEqual((late["final"], late["remaining"]), (True, 0))
+ self.assertEqual(_as_json(late["df_display_args"]), _as_json(session.df_display_args))
+ b.write_message(_stats_request(gen, incremental=True))
+ self.assertNotIn("df_display_args", await _read_json(b), "B holds it now")
+
+ @tornado.testing.gen_test
+ async def test_the_final_reply_carries_no_display_args_when_they_did_not_change(self):
+ build = str(xo.build_expr(xo.memtable({"qty": [1, 2, 1, 3, 2], "category": ["a", "b", "a", "c", "b"]},
+ name="t2"), builds_dir=self.builds_root))
+ await self._load("sw-same-args", build_dir=build, stats_delivery="deferred")
+ ws, first = await self._connect("sw-same-args", caps="stats_update")
+ self._budget(0)
+ replies = await self._pull_stats(ws, self._stats(first)["gen"])
+ self.assertEqual(len(replies), 3, "the scalar batch and one histogram for each of two columns")
+ self.assertTrue(replies[-1]["final"])
+ self.assertNotIn("df_display_args", replies[-1], "int and string columns are styled the same at both tiers")
+
+ @tornado.testing.gen_test
+ async def test_the_final_display_args_keep_the_clients_search_highlight(self):
+ await self._load("sw-highlight", stats_delivery="deferred")
+ ws, first = await self._connect("sw-highlight", caps="stats_update")
+ ws.write_message(_state_change(search_string="ca"))
+ self._assert_pending(await _read_json(ws), self._stats(first)["gen"])
+ self._budget(0)
+ replies = await self._pull_stats(ws, self._stats(first)["gen"])
+ self.assertEqual(len(replies), 4)
+ columns = replies[-1]["df_display_args"]["main"]["df_viewer_config"]["column_config"]
+ self.assertTrue([cc for cc in columns if cc.get("displayer_args", {}).get("highlight_phrase") == ["ca"]],
+ "rebuilt config replaces the one with the highlight, so it must carry it")
+
+ @tornado.testing.gen_test
+ async def test_a_filtered_state_ends_as_complete_as_the_inline_session_s(self):
+ search = {"search": ["a"]}
+ inline = await self._inline_frame("sw-filt-inline", quick_command_args=search)
+ await self._load("sw-filt", stats_delivery="deferred")
+ ws, first = await self._connect("sw-filt", caps="stats_update")
+ ws.write_message(_state_change(quick_command_args=search))
+ gen = self._stats(await _read_json(ws))["gen"]
+ self._budget(0)
+ replies = await self._pull_stats(ws, gen)
+ self.assertEqual(len(replies), 4)
+ _, late = await self._connect("sw-filt", caps="stats_update")
+ self._assert_complete(late, gen, inline)
+
+ @tornado.testing.gen_test
+ async def test_a_legacy_client_connecting_mid_run_gets_a_complete_state_from_the_remaining_units(self):
+ inline = await self._inline_frame("sw-mid-inline")
+ await self._load("sw-mid", stats_delivery="deferred")
+ a, a_open = await self._connect("sw-mid", caps="stats_update")
+ gen = self._stats(a_open)["gen"]
+ self._budget(0)
+
+ with _count_stat_queries() as queries:
+ for _ in range(2):
+ a.write_message(_stats_request(gen, incremental=True))
+ self.assertFalse((await _read_json(a))["final"])
+ self.assertEqual(len(queries), 2)
+ _, legacy = await self._connect("sw-mid")
+ self.assertEqual(len(queries), 4, "the legacy client's frame ran the two units still to run")
+ a.write_message(_stats_request(gen, incremental=True))
+ reply = await _read_json(a)
+ self.assertEqual(len(queries), 4, "A is answered from the stats the legacy client's frame completed")
+ self._assert_complete(legacy, gen, inline)
+ self.assertEqual((reply["final"], reply["remaining"]), (True, 0))
+ self.assertEqual(_rows_by_stat(reply["payload"]), _rows_by_stat(inline["df_data_dict"]["all_stats"]))
+
+ @tornado.testing.gen_test
+ async def test_a_request_with_no_incremental_flag_finishes_a_run_that_has_progress(self):
+ inline = await self._inline_frame("sw-finish-inline")
+ await self._load("sw-finish", stats_delivery="deferred")
+ ws, first = await self._connect("sw-finish", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ self._budget(0)
+ with _count_stat_queries() as queries:
+ for _ in range(2):
+ ws.write_message(_stats_request(gen, incremental=True))
+ self.assertFalse((await _read_json(ws))["final"])
+ ws.write_message(_stats_request(gen))
+ update = await _read_json(ws)
+ self.assertEqual((update["type"], update["final"], update["remaining"]), ("stats_update", True, 0))
+ self.assertEqual(len(queries), 4, "the whole-run request ran the two units left, not all four")
+ self.assertEqual(_rows_by_stat(update["payload"]), _rows_by_stat(inline["df_data_dict"]["all_stats"]))
+
+ @tornado.testing.gen_test
+ async def test_a_concurrent_infinite_request_is_served_between_units_within_the_stall_bound(self):
+ await self._load("sw-stall", stats_delivery="deferred")
+ a, a_open = await self._connect("sw-stall", caps="stats_update")
+ b, _ = await self._connect("sw-stall", caps="stats_update")
+ gen = self._stats(a_open)["gen"]
+ self._wrapped_run("sw-stall", delay=0.04)
+
+ a.write_message(_stats_request(gen, incremental=True))
+ started = time.perf_counter()
+ rows = await self._infinite_window(b)
+ rows_latency = time.perf_counter() - started
+ first = await _read_json(a)
+
+ self.assertEqual((rows["type"], rows["length"]), ("infinite_resp", 5))
+ self.assertFalse(first["final"], "rows came back while the stats were still running")
+ self.assertEqual(self._session("sw-stall").stats_status, "pending")
+ replies = [first] + await self._pull_stats(a, gen)
+ self.assertTrue(replies[-1]["final"])
+ # Plan 1's proposed ceiling on the longest single request.
+ for reply in replies:
+ self.assertLess(reply["elapsed_ms"], 250)
+ self.assertLess(rows_latency, 0.25)
+
+ @tornado.testing.gen_test
+ async def test_the_first_initial_state_precedes_any_data_stat_query_and_rows_precede_the_stats(self):
+ with _count_stat_queries() as queries:
+ await self._load("sw-first", stats_delivery="deferred")
+ ws, first = await self._connect("sw-first", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ self._assert_pending(first, gen)
+ self.assertEqual((await self._infinite_window(ws))["length"], 5)
+ self.assertEqual(queries, [], "the first initial_state and the first rows precede every data stat query")
+
+ self._budget(0)
+ ws.write_message(_stats_request(gen, incremental=True))
+ reply = await _read_json(ws)
+ self.assertEqual((reply["type"], reply["final"]), ("stats_update", False))
+ self.assertEqual(len(queries), 1)
+ self.assertEqual((await self._infinite_window(ws))["type"], "infinite_resp")
+ self.assertEqual(self._session("sw-first").stats_status, "pending", "rows are served before stats complete")
+
+ @tornado.testing.gen_test
+ async def test_reload_expr_mid_run_bumps_the_generation_and_drops_the_run(self):
+ await self._load("sw-reload-run", stats_delivery="deferred")
+ ws, first = await self._connect("sw-reload-run", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ session = self._session("sw-reload-run")
+ self._budget(0)
+ ws.write_message(_stats_request(gen, incremental=True))
+ self.assertFalse((await _read_json(ws))["final"])
+ self.assertEqual(list(session.stat_runs), [(gen, "raw")])
+
+ resp = await _post(self.get_http_port(), "/reload_expr/sw-reload-run", {})
+ self.assertEqual(resp.code, 200, resp.body)
+ self._assert_pending(await _read_json(ws), gen + 1)
+ self.assertEqual(session.stat_runs, {})
+
+ with _count_stat_queries() as queries:
+ ws.write_message(_stats_request(gen, incremental=True))
+ aborted = await _read_json(ws)
+ self.assertEqual((aborted["reason"], aborted["current_gen"]), ("stale", gen + 1))
+ self.assertEqual(queries, [])
+ ws.write_message(_stats_request(gen + 1, incremental=True))
+ reply = await _read_json(ws)
+ self.assertEqual((reply["remaining"], len(queries)), (3, 1), "the new generation starts from its first unit")
+
+ @tornado.testing.gen_test
+ async def test_stats_unit_and_completion_spans_are_emitted_on_the_session_sink(self):
+ captured: list = []
+ with patch.object(telemetry, "make_http_sink", lambda url, **kw: captured.append):
+ await self._load("sw-spans", stats_delivery="deferred",
+ telemetry_url="http://companion.invalid/internal/telemetry")
+ ws, first = await self._connect("sw-spans", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ self._budget(0)
+ self.assertEqual(len(await self._pull_stats(ws, gen)), 4)
+
+ units = [r for r in captured if r["name"] == "stats.unit"]
+ self.assertEqual([r["attrs"]["unit"] for r in units],
+ ["batch", "histogram:price", "histogram:qty", "histogram:category"])
+ for record in units:
+ self.assertEqual(record["trace"], "sw-spans")
+ self.assertEqual((record["attrs"]["session"], record["attrs"]["stats_gen"]), ("sw-spans", gen))
+ self.assertEqual([r["attrs"]["phase"] for r in units], ["batch", "histogram", "histogram", "histogram"])
+ totals = [r for r in captured if r["name"] == "firstpull.stats_total"]
+ self.assertEqual(len(totals), 1, "one completion span for the run, not one for each request")
+ self.assertEqual((totals[0]["attrs"]["stats_gen"], totals[0]["attrs"]["units"]), (gen, 4))
+ self.assertGreater(totals[0]["attrs"]["run_secs"], 0)
+ requests = [r for r in captured if r["name"] == "stats.request"]
+ self.assertEqual([r["attrs"]["outcome"] for r in requests], ["update"] * 4)
+ self.assertEqual([r["attrs"]["units"] for r in requests], [1, 1, 1, 1])
+
+ @tornado.testing.gen_test
+ async def test_the_completion_span_reports_the_snapshot_cache_outcome_of_the_units(self):
+ captured: list = []
+ cache_root = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, cache_root, ignore_errors=True)
+ with patch.object(telemetry, "make_http_sink", lambda url, **kw: captured.append):
+ await self._load("sw-cache", stats_delivery="deferred", cache_storage_path=cache_root,
+ telemetry_url="http://companion.invalid/internal/telemetry")
+ ws, first = await self._connect("sw-cache", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ self._budget(3600)
+ self.assertEqual(len(await self._pull_stats(ws, gen)), 1)
+ # A reload builds a new dataflow with an empty in-process cache, so the
+ # same state's units now read their snapshots.
+ resp = await _post(self.get_http_port(), "/reload_expr/sw-cache", {})
+ self.assertEqual(resp.code, 200, resp.body)
+ await _read_json(ws)
+ self.assertEqual(len(await self._pull_stats(ws, gen + 1)), 1)
+
+ cold, warm = [r["attrs"] for r in captured if r["name"] == "firstpull.stats_total"]
+ self.assertEqual((cold["cache_status"], cold["cache_hits"], cold["cache_misses"]), ("miss", 0, 4))
+ self.assertEqual((warm["cache_status"], warm["cache_hits"], warm["cache_misses"]), ("hit", 4, 0))
+
+ @tornado.testing.gen_test
+ async def test_a_unit_that_raises_fails_the_generation_until_the_next(self):
+ await self._load("sw-unit-fail", stats_delivery="deferred")
+ ws, first = await self._connect("sw-unit-fail", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ session = self._session("sw-unit-fail")
+ self._wrapped_run("sw-unit-fail", fail_on="histogram:price")
+ self._budget(0)
+
+ ws.write_message(_stats_request(gen, incremental=True))
+ self.assertFalse((await _read_json(ws))["final"])
+ ws.write_message(_stats_request(gen, incremental=True))
+ aborted = await _read_json(ws)
+ self.assertEqual((aborted["type"], aborted["stats_gen"], aborted["reason"]), ("stats_aborted", gen, "error"))
+ self.assertEqual((session.stats_status, session.stats_reason), ("error", "stats_failed"))
+ self.assertEqual(session.stat_runs, {})
+
+ with _count_stat_queries() as queries:
+ ws.write_message(_stats_request(gen, incremental=True))
+ self.assertEqual((await _read_json(ws))["reason"], "error")
+ self.assertEqual(queries, [], "a failed generation is not retried by every request")
+ self.assertEqual(list(_rows_by_stat(session.df_data_dict["all_stats"])), ["dtype"],
+ "the snapshot keeps the schema tier")
+
+ ws.write_message(_state_change(quick_command_args={"search": ["a"]}))
+ self._assert_pending(await _read_json(ws), gen + 1)
+ self.assertEqual(len(await self._pull_stats(ws, gen + 1)), 4)
+
+ @tornado.testing.gen_test
+ async def test_a_non_boolean_incremental_flag_is_aborted_as_a_bad_request(self):
+ await self._load("sw-bad-flag", stats_delivery="deferred")
+ ws, first = await self._connect("sw-bad-flag", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ with _count_stat_queries() as queries:
+ ws.write_message(_stats_request(gen, incremental="yes"))
+ aborted = await _read_json(ws)
+ self.assertEqual((aborted["type"], aborted["reason"], aborted["stats_gen"]),
+ ("stats_aborted", "bad_request", gen))
+ self.assertEqual(queries, [], "a malformed request must not fall back to the whole run")
+ self._budget(0)
+ self.assertFalse((await self._pull_stats(ws, gen, limit=1))[0]["final"],
+ "the session still serves a good request")
+
+ @tornado.testing.gen_test
+ async def test_an_incremental_request_for_a_state_with_cached_stats_runs_no_unit(self):
+ await self._load("sw-inc-cached", stats_delivery="deferred")
+ ws, first = await self._connect("sw-inc-cached", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ self._budget(0)
+ self.assertEqual(len(await self._pull_stats(ws, gen)), 4)
+ ws.write_message(_state_change(quick_command_args={"search": ["a"]}))
+ self._assert_pending(await _read_json(ws), gen + 1)
+ self.assertEqual(len(await self._pull_stats(ws, gen + 1)), 4)
+
+ ws.write_message(_state_change(quick_command_args={}))
+ self._assert_pending(await _read_json(ws), gen + 2)
+ with _count_stat_queries() as queries:
+ replies = await self._pull_stats(ws, gen + 2)
+ self.assertEqual([(r["final"], r["remaining"]) for r in replies], [(True, 0)])
+ self.assertEqual(queries, [], "the first state's stats are in summary_stats_cache")
+ self.assertEqual(self._session("sw-inc-cached").stat_runs, {})
+
+ @tornado.testing.gen_test
+ async def test_an_incremental_request_that_cannot_plan_units_takes_the_whole_run_path(self):
+ """A post-processor that fails leaves an error frame, which the xorq
+ stats class cannot plan units for. The request is still answered, as the
+ whole run is."""
+ with open(os.path.join(self.project_root, "post_processing", "boom.py"), "w") as f:
+ f.write("def process(expr):\n raise ValueError('boom')\n")
+ await self._load("sw-no-plan", stats_delivery="deferred")
+ ws, first = await self._connect("sw-no-plan", caps="stats_update")
+ gen = self._stats(first)["gen"]
+ ws.write_message(_state_change(post_processing="boom"))
+ pushed = await _read_json(ws)
+ self.assertEqual((self._stats(pushed)["status"], self._stats(pushed)["gen"]), ("pending", gen + 1))
+ self._budget(0)
+ with self.assertLogs("buckaroo.server.stats_wire", level="WARNING") as logs:
+ replies = await self._pull_stats(ws, gen + 1, limit=3)
+ self.assertIn("stat units not planned", logs.output[0])
+ self.assertEqual([(r["type"], r["final"]) for r in replies], [("stats_update", True)])
+ self.assertEqual(self._session("sw-no-plan").stats_status, "complete")
+
+ @tornado.testing.gen_test
+ async def test_a_columns_hint_that_is_not_a_list_of_names_is_only_a_hint(self):
+ await self._load("sw-junk-hint", stats_delivery="deferred")
+ ws, first = await self._connect("sw-junk-hint", caps="stats_update")
+ self._budget(0)
+ replies = await self._pull_stats(ws, self._stats(first)["gen"], columns=[["c"], 5, None, "c"])
+ self.assertEqual([r["type"] for r in replies], ["stats_update"] * 4)
+ self.assertEqual(self._session("sw-junk-hint").stat_runs, {})
+
+
+def _wide_parquet_expr(tmp_path, name="wide"):
+ """Twelve rows, six columns, read from parquet. Each string column has a
+ different count per value, so a GROUP BY over it returns one order."""
+ path = tmp_path / f"{name}.parquet"
+ pd.DataFrame({
+ "n0": [float(i) for i in range(12)], "n1": [i * 3 for i in range(12)],
+ "n2": [float(i % 7) + 0.5 for i in range(12)], "word": list("aaaaabbbbccd"),
+ "label": list("xxxxxyyyyzzw"), "flag": [True] * 7 + [False] * 5}).to_parquet(path)
+ return xo.deferred_read_parquet(str(path))
+
+
+def _batch_queries(queries):
+ return [q for q in queries if type(q.op()).__name__ == "Aggregate"]
+
+
+class TestStatUnits:
+ """Resumable units on the xorq server dataflow (rows-first s4): the
+ all-at-once constructor and a caller running ``plan``/``run`` one unit at a
+ time compute the same stats."""
+
+ def test_build_stats_without_running_issues_no_stat_query(self):
+ dataflow = _build_dataflow()
+ with _count_stat_queries() as queries:
+ stats = dataflow.build_stats(dataflow.processed_df, run=False)
+ stats.plan(stats.state)
+ stats.new_accumulator(stats.state)
+ assert queries == []
+ assert stats.sdf == {} and stats.errs == {}
+
+ def test_the_default_run_goes_through_the_units(self, monkeypatch):
+ """Every stat query of the constructor is issued from inside a unit."""
+ depth, inside, outside, units = [0], [], [], []
+ original_run, original_execute = XorqStatPipeline.run, XorqStatPipeline._execute
+
+ def spy_run(self, unit, acc):
+ depth[0] += 1
+ units.append(unit.id)
+ try:
+ return original_run(self, unit, acc)
+ finally:
+ depth[0] -= 1
+
+ def spy_execute(self, query):
+ (inside if depth[0] else outside).append(query)
+ return original_execute(self, query)
+
+ monkeypatch.setattr(XorqStatPipeline, "run", spy_run)
+ monkeypatch.setattr(XorqStatPipeline, "_execute", spy_execute)
+ _three_scope_dataflow()
+ assert inside and outside == []
+ assert units[0] == "batch" and "histogram:price" in units
+
+ def test_units_assembled_equal_merged_sd_with_init_sd_cleaning_and_overrides(self):
+ dataflow = _three_scope_dataflow(init_sd={
+ "price": {"displayer_args": {"displayer": "string", "max_length": 99}, "init_only": 1}},
+ column_config_overrides={"category": {"displayer_args": {"displayer": "string", "max_length": 5000}}})
+ dataflow.add_analysis(_OverridingPostProcessing)
+ dataflow.post_processing_method = "override_post"
+ sd = dataflow.merged_sd
+ assert sd["a"]["init_only"] == 1 and sd["b"]["from_post"] == 1 and sd["b"]["mean"] == 99.5
+ assert "cleaned_length" in sd["a"] and "filtered_length" in sd["a"], "both layers must be active"
+
+ sds = _scope_sds_by_units(dataflow)
+ inputs = _scope_inputs(dataflow)
+ assembled = assemble_merged_sd(init_sd=inputs["init_sd"], cleaned_sd=inputs["cleaned_sd"], raw_sd=sds["raw"],
+ processed_sd=inputs["processed_sd"], processed_df=inputs["processed_df"], chains=inputs["chains"],
+ clean_sd=sds["clean"], filt_sd=sds["filt"])
+
+ assert _as_json(assembled) == _as_json(dataflow.merged_sd)
+
+ def test_a_column_group_request_returns_only_those_columns(self):
+ dataflow = _build_dataflow()
+ stats = dataflow.build_stats(dataflow.processed_df, run=False)
+ for names in (("qty", "category"), ("b", "c")): # original or rewritten names
+ state = dataclasses.replace(stats.state, columns=names)
+ acc = stats.new_accumulator(state)
+ fragments = [stats.run(unit, acc) for unit in stats.plan(state)]
+ assert [u.id for u in stats.plan(state)] == ["batch", "histogram:qty", "histogram:category"]
+ assert all(set(f) <= {"qty", "category"} for f in fragments)
+ assert list(acc.sd()) == ["qty", "category"]
+
+ def test_skip_stat_columns_get_no_unit(self):
+ dataflow = _build_dataflow(skip_stat_columns=["qty"], init_sd={"qty": {"_type": "float"}})
+ stats = dataflow.build_stats(dataflow.processed_df, run=False)
+ assert stats.state.skip_columns == {"qty"}
+ units = stats.plan(stats.state)
+ assert [u.id for u in units] == ["batch", "histogram:price", "histogram:category"]
+ assert all("qty" not in unit.columns for unit in units)
+
+ def test_the_batch_is_one_query_by_default(self, tmp_path):
+ expr = _wide_parquet_expr(tmp_path)
+ with _count_stat_queries() as queries:
+ xorq_loading.XorqServerDataflow(expr, skip_main_serial=True)
+ assert len(_batch_queries(queries)) == 1
+
+ def test_stat_chunk_cells_splits_the_batch_of_a_parquet_scan(self, tmp_path):
+ expr = _wide_parquet_expr(tmp_path)
+ whole = xorq_loading.XorqServerDataflow(expr, skip_main_serial=True)
+ with _count_stat_queries() as queries:
+ # 36 cells at 12 rows is three columns per chunk: two batch queries.
+ split = xorq_loading.XorqServerDataflow(expr, skip_main_serial=True, stat_chunk_cells=36)
+ assert len(_batch_queries(queries)) == 2
+ assert _as_json(split.merged_sd) == _as_json(whole.merged_sd)
+
+ def test_the_split_is_refused_for_a_join(self, tmp_path):
+ left = _wide_parquet_expr(tmp_path, "left")
+ pd.DataFrame({"n0": [float(i) for i in range(12)], "extra": list(range(12))}).to_parquet(
+ tmp_path / "right.parquet")
+ joined = left.join(xo.deferred_read_parquet(str(tmp_path / "right.parquet")), "n0")
+ with _count_stat_queries() as queries:
+ split = xorq_loading.XorqServerDataflow(joined, skip_main_serial=True, stat_chunk_cells=12)
+ assert len(_batch_queries(queries)) == 1, "each chunk of a join would run the whole join again"
+ whole = xorq_loading.XorqServerDataflow(joined, skip_main_serial=True)
+ assert _as_json(split.merged_sd) == _as_json(whole.merged_sd)
+
+
class TestReloadExpr(tornado.testing.AsyncHTTPTestCase):
def get_app(self):
return make_app()
diff --git a/tests/unit/skip_columns_test.py b/tests/unit/skip_columns_test.py
index bf0121be5..1ffef9850 100644
--- a/tests/unit/skip_columns_test.py
+++ b/tests/unit/skip_columns_test.py
@@ -9,6 +9,7 @@
import pytest
from buckaroo.pluggable_analysis_framework.stat_pipeline import StatPipeline
+from buckaroo.pluggable_analysis_framework.stat_units import StatState
from buckaroo.customizations.pd_stats_v2 import PD_ANALYSIS_V2
@@ -61,6 +62,63 @@ def test_xorq_skip_columns_not_computed(tmp_path):
assert "mean" not in sd["b"]
+# ---------------------------------------------------------------------------
+# Resumable units (rows-first s4): a skipped column gets no unit, so running
+# every unit never touches it, and it still ends up in the result.
+# ---------------------------------------------------------------------------
+
+
+def _run_units(pipe, state):
+ acc = pipe.new_accumulator(state)
+ return acc, [pipe.run(unit, acc) for unit in pipe.plan(state)]
+
+
+def test_pandas_skipped_column_gets_no_unit():
+ df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9]})
+ pipe = StatPipeline(PD_ANALYSIS_V2, unit_test=False)
+ state = StatState(df, skip_columns=frozenset({"b"}))
+ assert [u.columns for u in pipe.plan(state)] == [("a",), ("c",)]
+ acc, fragments = _run_units(pipe, state)
+ assert [list(f) for f in fragments] == [["a"], ["c"]]
+ assert list(acc.sd()) == ["a", "b", "c"], "the skipped column keeps its place in the result"
+ assert acc.sd()["b"] == {"orig_col_name": "b", "rewritten_col_name": "b"}
+ assert pipe.process_df(df, skip_columns={"b"})[0]["b"] == acc.sd()["b"]
+
+
+def test_polars_skipped_column_gets_no_unit():
+ pl = pytest.importorskip("polars")
+ from buckaroo.customizations.pl_stats_v2 import PL_ANALYSIS_V2
+ df = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9]})
+ pipe = StatPipeline(PL_ANALYSIS_V2, unit_test=False)
+ state = StatState(df, skip_columns=frozenset({"b"}))
+ assert [u.columns for u in pipe.plan(state)] == [("a",), ("c",)]
+ acc, _fragments = _run_units(pipe, state)
+ assert set(acc.sd()["b"]) <= {"orig_col_name", "rewritten_col_name"}
+
+
+def test_xorq_skipped_column_gets_no_unit(tmp_path):
+ xo = pytest.importorskip("xorq.api")
+ from buckaroo.pluggable_analysis_framework.xorq_stat_pipeline import XorqStatPipeline
+ from buckaroo.customizations.xorq_stats_v2 import XORQ_STATS_V2
+
+ p = tmp_path / "t.parquet"
+ pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}).to_parquet(p)
+ expr = xo.deferred_read_parquet(str(p))
+
+ pipe = XorqStatPipeline(XORQ_STATS_V2)
+ state = StatState(expr, skip_columns=frozenset({"b"}))
+ units = pipe.plan(state)
+ assert all("b" not in unit.columns for unit in units)
+ assert [u.id for u in units] == ["batch", "histogram:a"]
+ acc, fragments = _run_units(pipe, state)
+ assert all("b" not in fragment for fragment in fragments)
+ sd = acc.sd()
+ # The column keeps what the batch gives every column, and no stat of its own.
+ assert sd["b"]["length"] == 3 and sd["b"]["dtype"] == "int64"
+ assert "mean" not in sd["b"] and "histogram" not in sd["b"]
+ assert sd == pipe.process_table(expr, skip_columns={"b"})[0]
+
+
# ---------------------------------------------------------------------------
# Notebook widget constructors must forward skip_stat_columns to the dataflow
# (it was only wired into CustomizableDataflow + the server /load handler, not
diff --git a/tests/unit/test_paf_v2.py b/tests/unit/test_paf_v2.py
index 6ec2c252c..e60b4e892 100644
--- a/tests/unit/test_paf_v2.py
+++ b/tests/unit/test_paf_v2.py
@@ -14,6 +14,7 @@
from buckaroo.pluggable_analysis_framework.typed_dag import (build_typed_dag, build_column_dag, DAGConfigError)
from buckaroo.pluggable_analysis_framework.column_filters import (is_numeric, is_string, is_temporal, is_boolean, any_of, not_)
from buckaroo.pluggable_analysis_framework.stat_pipeline import (StatPipeline, _normalize_inputs, errors_to_errdict)
+from buckaroo.pluggable_analysis_framework.stat_units import StatState, merge_fragments, rewrite_sd
from buckaroo.pluggable_analysis_framework.col_analysis import ColAnalysis
from buckaroo.pluggable_analysis_framework.utils import PERVERSE_DF
@@ -736,3 +737,235 @@ def test_stat_output_values(self):
assert col_stats['length'] == 4
assert isinstance(col_stats['distinct_per'], float)
assert col_stats['distinct_per'] > 0
+
+
+# ============================================================================
+# Characterization: process_df is process_column run over each column
+# ============================================================================
+
+def _mixed_frames():
+ """The same mixed-dtype columns as a pandas and a polars frame. Every column
+ has a different count for each of its values: polars does not keep the order
+ of equal counts, so ties would make two runs differ."""
+ pl = pytest.importorskip("polars")
+ data = {
+ 'ints': [3, 1, 2, 3, 3, 1],
+ 'floats': [0.5, 0.5, 0.5, 2.5, 2.5, None],
+ 'words': ['x', 'y', 'x', 'z', 'x', 'y'],
+ 'flags': [True, False, True, True, False, True]}
+ return {'pandas': pd.DataFrame(data), 'polars': pl.DataFrame(data)}
+
+
+def _stat_lists():
+ from buckaroo.customizations.pd_stats_v2 import PD_ANALYSIS_V2
+ from buckaroo.customizations.pl_stats_v2 import PL_ANALYSIS_V2
+ return {'pandas': PD_ANALYSIS_V2, 'polars': PL_ANALYSIS_V2}
+
+
+class TestProcessDfCharacterization:
+ """What ``process_df`` returns, written as the ``process_column`` loop it is
+ today, so a refactor of it into resumable units can be checked against
+ something that does not go through the units."""
+
+ @staticmethod
+ def _by_hand(pipeline, df, skip=()):
+ from buckaroo.df_util import old_col_new_col
+ expected = {}
+ for orig, rewritten in old_col_new_col(df):
+ if orig in skip:
+ expected[rewritten] = {'orig_col_name': orig, 'rewritten_col_name': rewritten}
+ continue
+ ser = df[orig]
+ stats, _errors = pipeline.process_column(column_name=rewritten, column_dtype=ser.dtype, raw_series=ser,
+ sampled_series=ser, raw_dataframe=df,
+ initial_stats={'orig_col_name': orig, 'rewritten_col_name': rewritten})
+ expected[rewritten] = stats
+ return expected
+
+ @pytest.mark.parametrize('backend', ['pandas', 'polars'])
+ @pytest.mark.parametrize('skip', [(), ('floats',)])
+ def test_process_df_equals_the_column_loop(self, backend, skip):
+ df = _mixed_frames()[backend]
+ pipeline = StatPipeline(_stat_lists()[backend], unit_test=False)
+ result, errors = pipeline.process_df(df, skip_columns=set(skip))
+ expected = self._by_hand(pipeline, df, skip)
+ assert errors == []
+ assert list(result) == list(expected), "columns must keep the frame's order"
+ for col in expected:
+ assert repr(result[col]) == repr(expected[col]), col
+
+ def test_errors_are_collected_per_column(self):
+ @stat()
+ def fails_on_words(ser: RawSeries) -> int:
+ if isinstance(ser.iloc[0], str):
+ raise ValueError('words')
+ return 0
+
+ pipeline = StatPipeline([length, fails_on_words], unit_test=False)
+ _result, errors = pipeline.process_df(_mixed_frames()['pandas'])
+ assert [(e.column, e.stat_key) for e in errors] == [('c', 'fails_on_words')]
+
+
+# ============================================================================
+# Resumable units: plan(state) and run(unit, acc) (rows-first s4)
+# ============================================================================
+
+class TestStatUnits:
+ """``plan(state)`` lists the units of a run, one per column, and
+ ``run(unit, acc)`` runs one and returns a ``{orig_col: {stat: value}}``
+ fragment. ``process_df`` is these two run over every unit, so a caller that
+ runs them one at a time gets what it would have got all at once."""
+
+ @staticmethod
+ def _df():
+ # Original names differ from the rewritten a, b, c.
+ return pd.DataFrame({'amount': [1, 2, 3, 1], 'word': ['x', 'y', 'x', 'x'], 'score': [0.5, 1.5, None, 2.5]})
+
+ @staticmethod
+ def _pipeline():
+ return StatPipeline([length, null_count, distinct_count, distinct_per, nan_per], unit_test=False)
+
+ @staticmethod
+ def _run_units(pipeline, state):
+ acc = pipeline.new_accumulator(state)
+ fragments = [pipeline.run(unit, acc) for unit in pipeline.plan(state)]
+ return acc, fragments
+
+ def test_plan_is_one_unit_per_column_in_column_order(self):
+ units = self._pipeline().plan(StatState(self._df()))
+ assert [u.columns for u in units] == [('amount',), ('word',), ('score',)]
+ assert len({u.id for u in units}) == 3
+ assert {u.phase for u in units} == {'column'}
+ assert all(u.after == () for u in units), "no column's stats read another column's"
+
+ def test_plan_and_a_new_accumulator_compute_nothing(self, monkeypatch):
+ calls = []
+ monkeypatch.setattr(StatPipeline, 'process_column', lambda self, *a, **k: calls.append(a))
+ pipeline, state = self._pipeline(), StatState(self._df())
+ pipeline.plan(state)
+ pipeline.new_accumulator(state)
+ assert calls == []
+
+ def test_run_returns_the_fragment_of_its_unit_keyed_by_original_name(self):
+ pipeline, state = self._pipeline(), StatState(self._df())
+ acc = pipeline.new_accumulator(state)
+ fragment = pipeline.run(pipeline.plan(state)[1], acc)
+ assert list(fragment) == ['word']
+ assert fragment['word']['length'] == 4
+ assert fragment['word']['distinct_count'] == 2
+ assert (fragment['word']['orig_col_name'], fragment['word']['rewritten_col_name']) == ('word', 'b')
+ assert list(acc.sd()) == ['word'], "a column with no unit run yet has no entry"
+
+ def test_the_union_of_fragments_is_what_process_df_returns(self):
+ df, pipeline = self._df(), self._pipeline()
+ acc, fragments = self._run_units(pipeline, StatState(df))
+ expected, errors = pipeline.process_df(df)
+ assert rewrite_sd(merge_fragments(fragments), df) == expected
+ assert list(acc.sd()) == ['amount', 'word', 'score']
+ assert [(e.column, e.stat_key) for e in acc.errors] == [(e.column, e.stat_key) for e in errors]
+
+ def test_an_error_in_a_unit_is_recorded_on_the_accumulator(self):
+
+ @stat()
+ def fails_on_words(ser: RawSeries) -> int:
+ if isinstance(ser.iloc[0], str):
+ raise ValueError('words')
+ return 0
+
+ pipeline = StatPipeline([length, fails_on_words], unit_test=False)
+ acc, fragments = self._run_units(pipeline, StatState(self._df()))
+ assert [(e.column, e.stat_key) for e in acc.errors] == [('b', 'fails_on_words')]
+ assert fragments[1]['word']['fails_on_words'] is None
+
+ @pytest.mark.parametrize('names', [('word',), ('b',)])
+ def test_a_column_group_returns_only_those_columns(self, names):
+ """A group is named by original or rewritten name, since the client
+ only knows the rewritten ones."""
+ pipeline = self._pipeline()
+ state = StatState(self._df(), columns=names)
+ assert [u.columns for u in pipeline.plan(state)] == [('word',)]
+ acc, fragments = self._run_units(pipeline, state)
+ assert [list(f) for f in fragments] == [['word']]
+ assert list(acc.sd()) == ['word']
+
+ @pytest.mark.parametrize('names', [('score',), ('c',)])
+ def test_priority_columns_are_planned_first(self, names):
+ units = self._pipeline().plan(StatState(self._df(), priority=names))
+ assert [u.columns[0] for u in units] == ['score', 'amount', 'word']
+
+ def test_an_empty_frame_has_no_units(self):
+ pipeline, state = self._pipeline(), StatState(pd.DataFrame({'x': pd.Series([], dtype='int64')}))
+ assert pipeline.plan(state) == []
+ assert pipeline.new_accumulator(state).sd() == {}
+
+ @pytest.mark.parametrize('backend', ['pandas', 'polars'])
+ def test_real_stats_run_unit_by_unit_equal_process_df(self, backend):
+ df = _mixed_frames()[backend]
+ pipeline = StatPipeline(_stat_lists()[backend], unit_test=False)
+ acc, fragments = self._run_units(pipeline, StatState(df))
+ expected, errors = pipeline.process_df(df)
+ assert len(fragments) == len(df.columns)
+ assert repr(rewrite_sd(merge_fragments(fragments), df)) == repr(expected)
+ assert errors == [] and acc.errors == []
+
+ # A name can be an original column name and, at the same time, the
+ # rewritten name of another column. ``permuted`` has that: its rewritten
+ # names are c -> a, b -> b, a -> c.
+ @staticmethod
+ def _permuted():
+ return pd.DataFrame({'c': [1, 2, 3, 1], 'b': ['x', 'y', 'x', 'x'], 'a': [0.5, 1.5, None, 2.5]})
+
+ @pytest.mark.parametrize('columns, expected', [
+ (('c',), ['c']),
+ (('a',), ['a']),
+ (('a', 'c'), ['c', 'a']),
+ (('b',), ['b']),
+ ])
+ def test_a_name_that_is_an_original_name_picks_that_column_only(self, columns, expected):
+ """'c' is the original name of one column and the rewritten name of
+ another. Read once, as the original name, it asks for one column."""
+ state = StatState(self._permuted(), columns=columns)
+ assert [u.columns[0] for u in self._pipeline().plan(state)] == expected
+
+ def test_a_rewritten_name_that_is_no_original_name_still_picks_its_column(self):
+ df = self._permuted().rename(columns={'b': 'word'})
+ state = StatState(df, columns=('b',))
+ assert [u.columns for u in self._pipeline().plan(state)] == [('word',)]
+
+ @pytest.mark.parametrize('columns, expected', [
+ (('c',), ['a']),
+ (('a',), ['c']),
+ (('b',), ['b']),
+ (('a', 'c'), ['c', 'a']),
+ ])
+ def test_the_rewritten_namespace_reads_every_name_as_a_rewritten_name(self, columns, expected):
+ state = StatState(self._permuted(), columns=columns, namespace='rewritten')
+ pipeline = self._pipeline()
+ assert [u.columns[0] for u in pipeline.plan(state)] == expected
+ acc, fragments = self._run_units(pipeline, state)
+ assert list(acc.sd()) == expected and [list(f) for f in fragments] == [[c] for c in expected]
+
+ def test_the_original_namespace_ignores_a_rewritten_name(self):
+ df = self._permuted().rename(columns={'b': 'word'})
+ state = StatState(df, columns=('b', 'c'), namespace='original')
+ assert [u.columns for u in self._pipeline().plan(state)] == [('c',)]
+
+ def test_a_group_outside_the_frame_is_empty(self):
+ assert self._pipeline().plan(StatState(self._permuted(), columns=('zzz',))) == []
+
+ def test_priority_reads_a_name_that_is_an_original_name_as_that_column(self):
+ state = StatState(self._permuted(), priority=('a',))
+ assert [u.columns[0] for u in self._pipeline().plan(state)] == ['a', 'c', 'b']
+
+ @pytest.mark.parametrize('namespace, priority, expected', [
+ ('original', ('a',), ['a', 'c', 'b']),
+ ('rewritten', ('c',), ['a', 'c', 'b']),
+ ('rewritten', ('a',), ['c', 'b', 'a']),
+ ])
+ def test_priority_reads_each_name_once_in_its_namespace(self, namespace, priority, expected):
+ state = StatState(self._permuted(), priority=priority, namespace=namespace)
+ assert [u.columns[0] for u in self._pipeline().plan(state)] == expected
+
+ def test_an_unknown_namespace_is_an_error(self):
+ with pytest.raises(ValueError, match='namespace'):
+ self._pipeline().plan(StatState(self._permuted(), columns=('c',), namespace='both'))
diff --git a/tests/unit/test_xorq_buckaroo_widget.py b/tests/unit/test_xorq_buckaroo_widget.py
index 196175748..9e8f4486f 100644
--- a/tests/unit/test_xorq_buckaroo_widget.py
+++ b/tests/unit/test_xorq_buckaroo_widget.py
@@ -60,6 +60,75 @@ def counting(self, q):
)
+_CHANGE_ORDER_TRAITS = [
+ "summary_sd", "merged_sd", "widget_args_tuple", "df_data_dict",
+ "df_display_args", "df_meta", "operations", "raw_sd_key", "clean_sd_key",
+ "filt_sd_key", "summary_stats_cache", "processed_result", "cleaned"]
+
+# Order in which the dataflow's observers see a search change on a default
+# (full-tier) XorqBuckarooWidget, recorded before ``_handle_widget_change`` is
+# split into separately callable halves. The split must leave it unchanged:
+# df_data_dict, then df_display_args, then widget_args_tuple's other observers.
+_SEARCH_CHANGE_ORDER = [
+ ("widget", "df_data_dict"), ("dataflow", "df_data_dict"),
+ ("widget", "df_display_args"), ("dataflow", "df_display_args"),
+ ("dataflow", "widget_args_tuple"), ("dataflow", "merged_sd"),
+ ("dataflow", "summary_sd"), ("dataflow", "processed_result"),
+ ("widget", "df_meta"), ("dataflow", "df_meta"), ("dataflow", "cleaned"),
+ ("dataflow", "summary_stats_cache"), ("dataflow", "widget_args_tuple"),
+ ("dataflow", "merged_sd"), ("dataflow", "filt_sd_key"),
+ ("widget", "operations"), ("dataflow", "operations")]
+
+_FULL_TIER_STAT_KEYS = {
+ "a": ["_type", "distinct_count", "distinct_per", "dtype", "histogram",
+ "histogram_bins", "is_bool", "is_datetime", "is_float", "is_integer",
+ "is_numeric", "is_string", "length", "max", "mean", "median", "min",
+ "nan_per", "non_null_count", "null_count", "orig_col_name",
+ "rewritten_col_name", "std"]}
+
+
+class TestWidgetChangeSequence:
+ """Characterization of what a default-tier widget publishes and in what
+ order, so the ``_handle_widget_change`` split (rows-first s1) can be shown
+ to leave a full-stats widget unchanged."""
+
+ def _widget(self):
+ return XorqBuckarooWidget(xo.memtable(
+ {"price": [12.5, 18.9, 7.4, 22.1, 14.0], "qty": [1, 2, 1, 3, 2],
+ "category": ["a", "b", "a", "c", "b"]}))
+
+ def test_search_change_keeps_trait_order(self):
+ w = self._widget()
+ seen = []
+ w.dataflow.observe(
+ lambda c: seen.append(("dataflow", c["name"])), names=_CHANGE_ORDER_TRAITS)
+ w.observe(
+ lambda c: seen.append(("widget", c["name"])),
+ names=["df_data_dict", "df_display_args", "df_meta", "operations"])
+ w.dataflow.quick_command_args = {"search": ["a"]}
+ assert seen == _SEARCH_CHANGE_ORDER
+
+ def test_merged_sd_and_display_state_unchanged(self):
+ w = self._widget()
+ msd = w.dataflow.merged_sd
+ assert sorted(msd["a"]) == _FULL_TIER_STAT_KEYS["a"]
+ assert {col: (sd["length"], sd["_type"], sd["dtype"]) for col, sd in msd.items()} == {
+ "a": (5, "float", "float64"), "b": (5, "integer", "int64"),
+ "c": (5, "string", "string")}
+ assert msd["a"]["mean"] == pytest.approx(14.98)
+ assert list(w.df_data_dict) == ["main", "all_stats", "empty"]
+ main = w.df_display_args["main"]
+ assert [pr["primary_key_val"] for pr in main["df_viewer_config"]["pinned_rows"]] == [
+ "dtype", "histogram"]
+ assert [cc["col_name"] for cc in main["df_viewer_config"]["column_config"]] == [
+ "a", "b", "c"]
+
+ w.dataflow.quick_command_args = {"search": ["a"]}
+ msd = w.dataflow.merged_sd
+ assert {col: (sd["length"], sd["filtered_length"]) for col, sd in msd.items()} == {
+ "a": (5, 2), "b": (5, 2), "c": (5, 2)}
+
+
class TestExprCountMemoization:
"""Regression for #795: ``_expr_count`` issued a fresh
``expr.count().execute()`` against the backend on every call, even
diff --git a/tests/unit/test_xorq_stats_v2.py b/tests/unit/test_xorq_stats_v2.py
index 3565ef385..3e420cec9 100644
--- a/tests/unit/test_xorq_stats_v2.py
+++ b/tests/unit/test_xorq_stats_v2.py
@@ -19,6 +19,7 @@
XorqStatPipeline,
XorqColumn)
from buckaroo.pluggable_analysis_framework.stat_func import stat # noqa: E402
+from buckaroo.pluggable_analysis_framework.stat_units import StatState, merge_fragments # noqa: E402
from buckaroo.customizations.xorq_stats_v2 import ( # noqa: E402
XORQ_STATS_V2)
@@ -876,3 +877,396 @@ def test_cached_stats_match_uncached(self, tmp_path):
for col in plain:
for key in ("length", "min", "max", "mean", "null_count", "distinct_count"):
assert cold[col].get(key) == plain[col].get(key), (col, key)
+
+
+class _RecordingCache:
+ """Wraps a snapshot cache and records the key of every query the pipeline
+ asks it about."""
+
+ def __init__(self, inner):
+ self.inner = inner
+ self.storage = inner.storage
+ self.keys = []
+
+ def calc_key(self, query):
+ key = self.inner.calc_key(query)
+ self.keys.append(key)
+ return key
+
+
+class TestHistogramCacheKeys:
+ """The snapshot-cache key of each per-column histogram query, which warm
+ loads in another process depend on. The expected keys come from queries
+ built here in the shape ``histogram`` documents, not from the pipeline, so
+ a change to how the pipeline builds or schedules them is caught."""
+
+ @staticmethod
+ def _scan(tmp_path):
+ df = pd.DataFrame({
+ "num": [float(i) for i in range(12)], "word": list("aabbccddeeff"), "flag": [True, False] * 6})
+ path = tmp_path / "keys.parquet"
+ df.to_parquet(path)
+ return df, xo.deferred_read_parquet(str(path))
+
+ @staticmethod
+ def _numeric_histogram_query(expr, col, lo, hi):
+ bucket = ((expr[col].cast("float64") - lo) / (hi - lo) * 10).cast("int64").clip(lower=0, upper=9)
+ return (expr.mutate(__bucket=bucket).group_by("__bucket").aggregate(__count=lambda t: t.count())
+ .order_by("__bucket"))
+
+ @staticmethod
+ def _categorical_histogram_query(expr, col):
+ return (expr.group_by(col).aggregate(__count=lambda t: t.count()).order_by(xo.desc("__count")).limit(10))
+
+ def test_each_histogram_query_keeps_its_snapshot_key(self, tmp_path):
+ df, table = self._scan(tmp_path)
+ cache = xo.ParquetSnapshotCache.from_kwargs(source=xo.connect(), base_path=str(tmp_path / "cache"))
+ recorder = _RecordingCache(cache)
+
+ XorqStatPipeline(XORQ_STATS_V2, unit_test=False, cache_storage=recorder).process_table(table)
+
+ expected = {
+ "num": cache.calc_key(self._numeric_histogram_query(table, "num", float(df.num.min()), float(df.num.max()))),
+ "word": cache.calc_key(self._categorical_histogram_query(table, "word")),
+ "flag": cache.calc_key(self._categorical_histogram_query(table, "flag"))}
+ for col, key in expected.items():
+ assert key in recorder.keys, f"the {col} histogram query is no longer keyed as it was"
+ # The batch aggregate and one histogram query per column, nothing else.
+ assert len(recorder.keys) == 1 + len(expected)
+ assert len(set(recorder.keys)) == len(recorder.keys)
+
+
+# ============================================================
+# Resumable units: plan(state) and run(unit, acc) (rows-first s4)
+# ============================================================
+
+_STATS_LOGGER = "buckaroo.pluggable_analysis_framework.xorq_stat_pipeline"
+
+
+def _stable(sd):
+ """An sd as comparable JSON. Equal counts come back from a GROUP BY in no
+ fixed order, so the histogram lists are sorted, and the partial sums of a
+ parallel aggregate add in an order that changes the last digit of a float,
+ so floats are compared to nine significant digits."""
+ import json
+
+ def norm(value, key=None):
+ if isinstance(value, dict):
+ return {k: norm(v, k) for k, v in value.items()}
+ if isinstance(value, list):
+ items = [norm(v) for v in value]
+ return sorted(items, key=lambda item: json.dumps(item, sort_keys=True)) if key == "histogram" else items
+ if isinstance(value, float):
+ return float(f"{value:.9g}")
+ return value
+
+ return json.dumps(norm(sd), sort_keys=True, default=str)
+
+
+class _QuerySpy:
+ """Records every query ``XorqStatPipeline`` executes while it is active.
+ ``kinds()`` tells the batch aggregate, whose outermost op is an Aggregate,
+ from a histogram query, which is ordered or limited on top of one."""
+
+ def __init__(self, monkeypatch):
+ self.queries = []
+ original = XorqStatPipeline._execute
+
+ def spy(pipeline, query):
+ self.queries.append(query)
+ return original(pipeline, query)
+
+ monkeypatch.setattr(XorqStatPipeline, "_execute", spy)
+
+ def kinds(self):
+ return ["batch" if type(q.op()).__name__ == "Aggregate" else "histogram" for q in self.queries]
+
+
+def _run_units(pipeline, state):
+ acc = pipeline.new_accumulator(state)
+ return acc, [(unit, pipeline.run(unit, acc)) for unit in pipeline.plan(state)]
+
+
+class TestStatUnits:
+ """``plan(state)`` is the scalar batch first, then one histogram GROUP BY
+ per column; ``run(unit, acc)`` runs one unit and returns the
+ ``{column: {stat: value}}`` fragment it produced. ``process_table`` is
+ these run in order."""
+
+ _ALL = ("ints", "floats", "strs", "bools")
+
+ def test_the_scalar_batch_is_first_then_one_histogram_unit_per_column(self):
+ units = XorqStatPipeline(XORQ_STATS_V2, unit_test=False).plan(StatState(_make_table()))
+ assert [u.id for u in units] == ["batch", *(f"histogram:{c}" for c in self._ALL)]
+ assert units[0].columns == self._ALL
+ assert [u.columns for u in units[1:]] == [(c,) for c in self._ALL]
+ assert [u.phase for u in units] == ["batch", *["histogram"] * 4]
+ assert units[0].after == ()
+ assert all(u.after == ("batch",) for u in units[1:])
+
+ @pytest.mark.parametrize("visible", [("strs", "bools"), ("c", "d")])
+ def test_visible_columns_get_their_histogram_units_first(self, visible):
+ """Visible columns are named by original or rewritten name. The batch
+ stays first, since every histogram reads what it computes."""
+ units = XorqStatPipeline(XORQ_STATS_V2, unit_test=False).plan(StatState(_make_table(), priority=visible))
+ assert [u.id for u in units] == [
+ "batch", "histogram:strs", "histogram:bools", "histogram:ints", "histogram:floats"]
+
+ def test_plan_and_a_new_accumulator_issue_no_query(self, monkeypatch):
+ spy = _QuerySpy(monkeypatch)
+ pipeline, state = XorqStatPipeline(XORQ_STATS_V2, unit_test=False), StatState(_make_table())
+ pipeline.plan(state)
+ pipeline.new_accumulator(state)
+ assert spy.queries == []
+
+ def test_the_batch_is_one_query_and_each_histogram_unit_one_more(self, monkeypatch):
+ spy = _QuerySpy(monkeypatch)
+ pipeline = XorqStatPipeline(XORQ_STATS_V2, unit_test=False)
+ state = StatState(_make_table())
+ acc = pipeline.new_accumulator(state)
+ for unit in pipeline.plan(state):
+ before = len(spy.queries)
+ pipeline.run(unit, acc)
+ assert len(spy.queries) - before == 1, unit.id
+ assert spy.kinds() == ["batch", "histogram", "histogram", "histogram", "histogram"]
+
+ def test_the_batch_fragment_has_the_scalar_stats_and_the_bins_but_no_histogram(self):
+ """``histogram_bins`` is a pure function of five scalars, so it comes
+ with the batch and ``color_map`` needs no histogram query."""
+ _acc, run = _run_units(XorqStatPipeline(XORQ_STATS_V2, unit_test=False), StatState(_make_table()))
+ batch_unit, batch = run[0]
+ assert set(batch) == set(self._ALL)
+ assert {"length", "null_count", "min", "max", "mean", "std", "median", "distinct_count", "non_null_count",
+ "nan_per", "distinct_per", "histogram_bins", "_type"} <= set(batch["ints"])
+ assert batch["ints"]["histogram_bins"][0] == 1.0 and len(batch["ints"]["histogram_bins"]) == 11
+ assert all("histogram" not in stats for stats in batch.values())
+ assert "mean" not in batch["strs"]
+
+ def test_a_histogram_fragment_holds_the_histogram_of_its_column_only(self):
+ _acc, run = _run_units(XorqStatPipeline(XORQ_STATS_V2, unit_test=False), StatState(_make_table()))
+ for unit, fragment in run[1:]:
+ assert list(fragment) == list(unit.columns)
+ assert set(fragment[unit.columns[0]]) == {"histogram"}
+
+ def test_the_union_of_fragments_is_process_table(self):
+ table = _make_table_with_nulls()
+ pipeline = XorqStatPipeline(XORQ_STATS_V2, unit_test=False)
+ acc, run = _run_units(pipeline, StatState(table))
+ expected, errors = pipeline.process_table(table)
+ assert _stable(merge_fragments(f for _u, f in run)) == _stable(expected)
+ assert _stable(acc.sd()) == _stable(expected)
+ assert errors == [] and acc.errors == []
+
+ def test_a_failing_stat_is_recorded_once_and_the_run_goes_on(self):
+
+ @stat()
+ def will_fail(col: XorqColumn) -> int:
+ raise RuntimeError("intentional")
+
+ pipeline = XorqStatPipeline([*XORQ_STATS_V2, will_fail], unit_test=False)
+ acc, run = _run_units(pipeline, StatState(_make_table()))
+ assert sorted(e.column for e in acc.errors if e.stat_key == "will_fail") == sorted(self._ALL)
+ assert len(acc.errors) == len({(e.column, e.stat_key) for e in acc.errors}), "an error is reported once"
+ assert run[0][1]["ints"]["will_fail"] is None
+ assert acc.sd()["ints"]["histogram"], "later units still ran"
+
+ @pytest.mark.parametrize("names", [("ints", "strs"), ("a", "c")])
+ def test_a_column_group_returns_only_those_columns(self, names, monkeypatch):
+ spy = _QuerySpy(monkeypatch)
+ pipeline = XorqStatPipeline(XORQ_STATS_V2, unit_test=False)
+ state = StatState(_make_table(), columns=names)
+ assert [u.id for u in pipeline.plan(state)] == ["batch", "histogram:ints", "histogram:strs"]
+ acc, run = _run_units(pipeline, state)
+ assert all(set(fragment) <= {"ints", "strs"} for _u, fragment in run)
+ assert list(acc.sd()) == ["ints", "strs"]
+ assert spy.kinds() == ["batch", "histogram", "histogram"]
+ full, _errors = pipeline.process_table(_make_table())
+ assert _stable(acc.sd()) == _stable({c: full[c] for c in ("ints", "strs")})
+
+ @staticmethod
+ def _permuted_table():
+ """'c' is the original name of one column and the rewritten name of
+ another: the rewritten names are c -> a, b -> b, a -> c."""
+ return xo.memtable(pd.DataFrame({"c": [1, 2, 3, 4, 5, 6, 7], "b": [7, 6, 5, 4, 3, 2, 1],
+ "a": [1, 3, 5, 7, 9, 11, 13]}))
+
+ @pytest.mark.parametrize("columns, expected", [(("c",), ["c"]), (("a",), ["a"]), (("a", "c"), ["c", "a"])])
+ def test_a_group_name_that_is_an_original_name_picks_that_column_only(self, columns, expected):
+ state = StatState(self._permuted_table(), columns=columns)
+ units = XorqStatPipeline(XORQ_STATS_V2, unit_test=False).plan(state)
+ assert [u.id for u in units] == ["batch", *(f"histogram:{c}" for c in expected)]
+ assert units[0].columns == tuple(expected)
+
+ @pytest.mark.parametrize("columns, namespace, expected", [
+ (("c",), "rewritten", ["a"]),
+ (("a",), "rewritten", ["c"]),
+ (("c",), "original", ["c"]),
+ ])
+ def test_a_group_reads_each_name_in_its_namespace(self, columns, namespace, expected):
+ state = StatState(self._permuted_table(), columns=columns, namespace=namespace)
+ units = XorqStatPipeline(XORQ_STATS_V2, unit_test=False).plan(state)
+ assert [u.id for u in units] == ["batch", *(f"histogram:{c}" for c in expected)]
+ assert units[0].columns == tuple(expected)
+
+ def test_priority_reads_a_name_that_is_an_original_name_as_that_column(self):
+ units = XorqStatPipeline(XORQ_STATS_V2, unit_test=False).plan(StatState(self._permuted_table(), priority=("a",)))
+ assert [u.id for u in units] == ["batch", "histogram:a", "histogram:c", "histogram:b"]
+
+ def test_priority_reads_each_name_in_the_rewritten_namespace(self):
+ state = StatState(self._permuted_table(), priority=("c",), namespace="rewritten")
+ units = XorqStatPipeline(XORQ_STATS_V2, unit_test=False).plan(state)
+ assert [u.id for u in units] == ["batch", "histogram:a", "histogram:c", "histogram:b"]
+
+
+# ============================================================
+# Splitting the batch by column chunk (rows-first s4)
+# ============================================================
+
+_SIX_COLUMNS = ("n0", "n1", "n2", "word", "label", "flag")
+_SIX_ROWS = 12
+
+
+def _six_column_frame():
+ """Twelve rows, six columns. Every string column has a different count for
+ each value, so a GROUP BY over it returns the same order every time."""
+ return pd.DataFrame({
+ "n0": [float(i) for i in range(_SIX_ROWS)], "n1": [i * 3 for i in range(_SIX_ROWS)],
+ "n2": [float(i % 7) + 0.5 for i in range(_SIX_ROWS)], "word": list("aaaaabbbbccd"),
+ "label": list("xxxxxyyyyzzw"), "flag": [True] * 7 + [False] * 5})
+
+
+def _six_column_scan(tmp_path):
+ path = tmp_path / "six.parquet"
+ _six_column_frame().to_parquet(path)
+ return xo.deferred_read_parquet(str(path))
+
+
+def _chunked(chunk_cells):
+ return XorqStatPipeline(XORQ_STATS_V2, unit_test=False, chunk_cells=chunk_cells)
+
+
+def _batch_units(units):
+ return [u for u in units if u.phase == "batch"]
+
+
+class TestColumnChunkSplit:
+ """The batch aggregate split into one aggregate per chunk of columns, sized
+ by cells (rows x columns). It is off unless the pipeline is given
+ ``chunk_cells``, and it applies only to a plain parquet scan, because each
+ chunk of a join, an aggregate or a CSV read runs the whole plan again."""
+
+ def test_the_batch_is_not_split_by_default(self, tmp_path):
+ units = XorqStatPipeline(XORQ_STATS_V2, unit_test=False).plan(
+ StatState(_six_column_scan(tmp_path), rows=_SIX_ROWS))
+ assert [u.id for u in _batch_units(units)] == ["batch"]
+
+ def test_chunks_are_sized_by_cells(self, tmp_path):
+ table = _six_column_scan(tmp_path)
+ # 36 cells at 12 rows is three columns per chunk, 24 is two, and a
+ # chunk always holds at least one column.
+ for cells, size in ((36, 3), (24, 2), (1, 1)):
+ batches = _batch_units(_chunked(cells).plan(StatState(table, rows=_SIX_ROWS)))
+ assert [len(u.columns) for u in batches] == [size] * (6 // size), cells
+ assert [c for u in batches for c in u.columns] == list(_SIX_COLUMNS)
+ assert [u.id for u in _batch_units(_chunked(36).plan(StatState(table, rows=_SIX_ROWS)))] == [
+ "batch:0", "batch:1"]
+
+ def test_a_chunk_that_fits_every_column_is_the_single_batch(self, tmp_path):
+ units = _chunked(10_000).plan(StatState(_six_column_scan(tmp_path), rows=_SIX_ROWS))
+ assert [u.id for u in _batch_units(units)] == ["batch"]
+
+ def test_batches_come_first_and_each_histogram_follows_its_chunk(self, tmp_path):
+ units = _chunked(36).plan(StatState(_six_column_scan(tmp_path), rows=_SIX_ROWS))
+ assert [u.phase for u in units] == ["batch"] * 2 + ["histogram"] * 6
+ chunk_of = {c: u.id for u in units[:2] for c in u.columns}
+ for unit in units[2:]:
+ assert unit.after == (chunk_of[unit.columns[0]],)
+
+ def test_visible_columns_fill_the_first_chunk(self, tmp_path):
+ units = _chunked(36).plan(StatState(_six_column_scan(tmp_path), rows=_SIX_ROWS, priority=("flag", "n2")))
+ assert _batch_units(units)[0].columns == ("flag", "n2", "n0")
+ assert [u.columns[0] for u in units if u.phase == "histogram"][:2] == ["flag", "n2"]
+
+ def test_an_unknown_row_count_keeps_the_single_batch(self, tmp_path):
+ """Cells need the row count, and asking for it is a query."""
+ units = _chunked(36).plan(StatState(_six_column_scan(tmp_path)))
+ assert [u.id for u in _batch_units(units)] == ["batch"]
+
+ def test_the_split_equals_the_single_batch(self, tmp_path, monkeypatch):
+ table = _six_column_scan(tmp_path)
+ single, errors = XorqStatPipeline(XORQ_STATS_V2, unit_test=False).process_table(table)
+ spy = _QuerySpy(monkeypatch)
+ acc, _run = _run_units(_chunked(36), StatState(table, rows=_SIX_ROWS))
+ assert acc.errors == [] and errors == []
+ assert _stable(acc.sd()) == _stable(single)
+ assert spy.kinds() == ["batch"] * 2 + ["histogram"] * 6
+
+ def test_process_table_takes_the_row_count_and_splits(self, tmp_path, monkeypatch):
+ table = _six_column_scan(tmp_path)
+ single, _errors = XorqStatPipeline(XORQ_STATS_V2, unit_test=False).process_table(table)
+ spy = _QuerySpy(monkeypatch)
+ split, errors = _chunked(24).process_table(table, rows=_SIX_ROWS)
+ assert errors == []
+ assert _stable(split) == _stable(single)
+ assert spy.kinds().count("batch") == 3
+
+ def test_the_split_leaves_the_histogram_cache_keys_alone(self, tmp_path):
+ """A histogram query does not depend on how the batch was cut, so a
+ snapshot written before the split is still read after it."""
+ table = _six_column_scan(tmp_path)
+
+ def keys(name, **kwargs):
+ cache = xo.ParquetSnapshotCache.from_kwargs(source=xo.connect(), base_path=str(tmp_path / name))
+ recorder = _RecordingCache(cache)
+ XorqStatPipeline(XORQ_STATS_V2, unit_test=False, cache_storage=recorder, **kwargs).process_table(
+ table, rows=_SIX_ROWS)
+ return recorder.keys
+
+ single, split = keys("single"), keys("split", chunk_cells=36)
+ assert len(single) == 1 + 6 and len(split) == 2 + 6
+ assert split[2:] == single[1:], "the six histogram queries keep their keys and their order"
+ assert not set(split[:2]) & set(single), "each chunk is a different batch query"
+
+ def test_a_plain_parquet_scan_and_a_column_projection_of_it_may_be_split(self, tmp_path):
+ table = _six_column_scan(tmp_path)
+ assert XorqStatPipeline.chunk_refusal(table) is None
+ assert XorqStatPipeline.chunk_refusal(table.select("n0", "word", "flag")) is None
+
+ @staticmethod
+ def _sources(tmp_path):
+ """Expressions that are not a plain parquet scan, by kind."""
+ frame = _six_column_frame()
+ frame.to_parquet(tmp_path / "left.parquet")
+ frame[["n0", "n1"]].rename(columns={"n1": "other"}).to_parquet(tmp_path / "right.parquet")
+ frame.to_csv(tmp_path / "six.csv", index=False)
+ left = xo.deferred_read_parquet(str(tmp_path / "left.parquet"))
+ right = xo.deferred_read_parquet(str(tmp_path / "right.parquet"))
+ outer = left.join(right, "n0", how="outer")
+ return {
+ "join": left.join(right, "n0"),
+ "aggregate": left.group_by("word").aggregate(total=left.n0.sum(), peak=left.n1.max()),
+ "diff": outer.mutate(delta=outer.other - outer.n1),
+ "csv": xo.deferred_read_csv(str(tmp_path / "six.csv")),
+ "filter": left.filter(left.n0 > 2),
+ "derived column": left.mutate(twice=left.n0 * 2),
+ "cached": left.cache(cache=xo.ParquetSnapshotCache.from_kwargs(
+ source=xo.connect(), base_path=str(tmp_path / "snapshots"))),
+ "memtable": xo.memtable(frame),
+ "backend table": xo.connect().read_parquet(str(tmp_path / "left.parquet"))}
+
+ @pytest.mark.parametrize("kind", ["join", "aggregate", "diff", "csv", "filter", "derived column", "cached",
+ "memtable", "backend table"])
+ def test_the_split_is_refused_for_anything_but_a_plain_parquet_scan(self, kind, tmp_path, monkeypatch, caplog):
+ table = self._sources(tmp_path)[kind]
+ assert XorqStatPipeline.chunk_refusal(table), kind
+
+ # Refused means the one batch it always was, and the same stats.
+ with caplog.at_level(logging.INFO, logger=_STATS_LOGGER):
+ units = _chunked(1).plan(StatState(table, rows=_SIX_ROWS))
+ assert [u.id for u in _batch_units(units)] == ["batch"]
+ assert any("refused" in r.getMessage() for r in caplog.records), "a refusal must be logged"
+ spy = _QuerySpy(monkeypatch)
+ split, _errors = _chunked(1).process_table(table, rows=_SIX_ROWS)
+ single, _errors = XorqStatPipeline(XORQ_STATS_V2, unit_test=False).process_table(table)
+ assert spy.kinds().count("batch") == 2 # one per run: the refused split and the single batch
+ assert _stable(split) == _stable(single)