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..22197f870 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,59 @@ 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. These + pipelines have only the full tier.""" + if state.tier != "full": + raise ValueError(f"the pandas and polars pipelines have only the full tier, not {state.tier!r}") + 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 +344,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 +464,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..611bb16ce --- /dev/null +++ b/buckaroo/pluggable_analysis_framework/stat_units.py @@ -0,0 +1,230 @@ +"""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" + + +# The tiers a stat run is planned at: ``full`` is every stat, and ``scalar`` the +# scalar class of them (the xorq pipeline says which, ``SCALAR_OMITTED_KEYS``). +# The ``schema`` tier is the dataflow's own and runs no units. +UNIT_TIERS = ("scalar", "full") + +# 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. ``tier`` is the tier the run is at + (see ``UNIT_TIERS``); a pipeline that has only the full tier refuses another. + """ + data: Any + skip_columns: frozenset = frozenset() + columns: Optional[Tuple[Any, ...]] = None + priority: Tuple[Any, ...] = () + rows: Optional[int] = None + namespace: str = "any" + tier: str = "full" + + def __post_init__(self) -> None: + check_namespace(self.namespace) + if self.tier not in UNIT_TIERS: + raise ValueError(f"tier must be one of {UNIT_TIERS}, not {self.tier!r}") + + +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..cec1a672a 100644 --- a/buckaroo/pluggable_analysis_framework/xorq_stat_pipeline.py +++ b/buckaroo/pluggable_analysis_framework/xorq_stat_pipeline.py @@ -1,14 +1,21 @@ """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. + +At the scalar tier (``StatState.tier``) a run is the batch alone, without the +aggregates that cost more than a pass over a column (``approx_median``, and +the ``approx_nunique`` behind ``distinct_count``) and with no histogram query. Errors are captured into ``StatError`` via the standard Ok/Err mechanism; nothing is silently swallowed. Construction validates the DAG up front and @@ -23,7 +30,7 @@ import os import time from contextlib import nullcontext -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple import pandas as pd @@ -33,6 +40,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 +50,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 +111,32 @@ 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 tier and dtype's stats split + (see ``XorqStatPipeline._column_phases``). ``withheld`` are keys the tier + does not report: they are in ``working`` (as the unknown ``None`` that + ``histogram_bins`` reads) but in no fragment and not in ``sd()``. ``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] = (), withheld: Iterable[str] = ()): + super().__init__(state, columns, rewritten) + self.skipped: Tuple[Any, ...] = tuple(skipped) + self.withheld: frozenset = frozenset(withheld) + 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() if key not in self.withheld} 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 +146,16 @@ 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``). + + ``StatState.tier`` is ``full`` (every stat) or ``scalar``: the batch alone, + in the same chunks, without the stats in ``SCALAR_OMITTED_KEYS``. """ # Keys that the pipeline pre-populates per column. Listed as external @@ -122,8 +167,17 @@ class XorqStatPipeline: {"orig_col_name", "rewritten_col_name", "dtype", "length", "min", "max", "distinct_count"}) + # What the scalar tier does not report. ``median`` (approx_median) and + # ``distinct_count`` (approx_nunique) are the batch aggregates it leaves + # out, ``distinct_per`` is the ratio of the second and ``histogram`` is the + # per-column query. ``histogram_bins`` is still produced, from ``min`` and + # ``max`` with ``distinct_count`` unknown, so a low-cardinality integer + # column gets bins that the full tier then replaces with none. A stat that + # reads an omitted key other than ``distinct_count`` is left out with it. + SCALAR_OMITTED_KEYS = frozenset({"median", "distinct_count", "distinct_per", "histogram"}) + 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 +192,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 +379,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,39 +404,189 @@ 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 _scalar_omits(self, sf: StatFunc) -> bool: + """Whether the scalar tier leaves ``sf`` out: it provides a key the + tier does not report.""" + return any(sk.name in self.SCALAR_OMITTED_KEYS for sk in sf.provides) + + def _column_phases(self, cache: Dict[Any, Any], dtype, + tier: str = "full") -> 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. At the scalar tier the + funcs that provide a key in ``SCALAR_OMITTED_KEYS`` are left out, and + so are those that read a key only they provide (``distinct_count`` is + external, so what reads it stays).""" + if (tier, dtype) not in cache: + funcs = self.all_stat_funcs if tier == "full" else [ + sf for sf in self.all_stat_funcs if not self._scalar_omits(sf)] + col_funcs = build_column_dag(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[(tier, dtype)] = (pure, query, key_to_func) + return cache[(tier, 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. The + scalar tier has the batch units alone. 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 state.tier == "full" else (): + 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 (the scalar tier withholds the ones it does not report, so + ``distinct_count`` stays the unknown ``None`` and is never returned). + ``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], + withheld=self.SCALAR_OMITTED_KEYS if state.tier == "scalar" else ()) + 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) + if acc.state.tier == "scalar": + raise ValueError(f"the scalar tier runs no {unit.phase} unit ({unit.id})") + 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 and the tier + reports, 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] and key not in acc.withheld} + 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 + tier = acc.state.tier + 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. TOTAL_LENGTH_KEY = "__total_length__" batch_items: List[Tuple[str, StatFunc, Any]] = [] for sf in self.ordered_stat_funcs: - if not _is_batch_func(sf): + if not _is_batch_func(sf) or (tier == "scalar" and self._scalar_omits(sf)): 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 +594,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 +604,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 +620,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], tier) + 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 +694,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 +709,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 +721,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 +756,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..60440c7bf 100644 --- a/buckaroo/server/handlers.py +++ b/buckaroo/server/handlers.py @@ -14,7 +14,11 @@ 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.server.session import ( + STATS_DELIVERIES, STATS_TIER_REQUESTS, begin_stats_generation, dataflow_stats_tier, reset_stats_controls) +from buckaroo.server.stats_wire import ( + apply_source_guards, broadcast_state, refresh_demand_columns, refresh_session_snapshot, resolve_session_guards, + resolve_session_policy) from buckaroo.server import telemetry from buckaroo.pluggable_analysis_framework import perf_log @@ -192,17 +196,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 +368,16 @@ async def post(self): **component_config, } + # /load serves the eager backends, whose stats cost does not grow with + # the file, so they resolve to full: it reads no stats_tier and 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, session.stats_policy = "full", "inline", None + session.stat_chunk_cells = None + session.source_guards = None + reset_stats_controls(session) + 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 +387,49 @@ 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`` (``auto``, ``full``, ``scalar`` or ``schema``) 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_TIER_REQUESTS: + return None, None, {"error_code": "invalid_stats_tier", + "message": f"stats_tier must be one of {list(STATS_TIER_REQUESTS)}, 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 + + +def _stat_chunk_cells_from_body(body: dict, current: int | None): + """Read ``stat_chunk_cells`` from a request body: the cell count the xorq + stats batch is cut by, or ``None`` for off. + + A body that omits the field keeps ``current`` (the session's, or ``None`` + for a new session), as the other stats fields do. An explicit ``null`` turns + the split off. Returns ``(stat_chunk_cells, None)``, or ``(None, error)`` + with the 400 response body for anything that is not a positive integer (a + bool and an integral float included).""" + if "stat_chunk_cells" not in body: + return current, None + stat_chunk_cells = body["stat_chunk_cells"] + if stat_chunk_cells is None: + return None, None + if isinstance(stat_chunk_cells, bool) or not isinstance(stat_chunk_cells, int) or stat_chunk_cells <= 0: + return None, {"error_code": "invalid_stat_chunk_cells", + "message": f"stat_chunk_cells must be a positive integer cell count or null, got {stat_chunk_cells!r}"} + return stat_chunk_cells, 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 +488,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, stats_delivery and stat_chunk_cells + # are not in this tuple: it tests truthiness, and a host that sends + # them on every POST would never get the warm exit (#944). They are + # compared with the session's stored values 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 +521,34 @@ 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 + stat_chunk_cells, chunk_error = _stat_chunk_cells_from_body( + body, existing.stat_chunk_cells if existing is not None else None) + if chunk_error is not None: + self.set_status(400) + self.write(chunk_error) + return + # What a client forced or the cost guard paused (SessionState.stats_override, + # cost_paused) belongs to one expression: a rebuild of the same one keeps it, + # and so does a re-POST that names no stats_tier. + same_expression = (existing is not None and existing.backend == "xorq" and existing.build_dir == build_dir + and existing.cache_dir == cache_dir) # /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) + and existing.stat_chunk_cells == stat_chunk_cells): # 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 @@ -525,6 +601,7 @@ async def post(self): "skip_stat_columns": body.get("skip_stat_columns")} project_root = body.get("project_root") + dataflow_tier = dataflow_stats_tier(stats_tier, stats_delivery) try: # session= correlates these spans across concurrent loads — the @@ -553,7 +630,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, - **dataflow_kwargs) + stats_tier=dataflow_tier, stat_chunk_cells=stat_chunk_cells, **dataflow_kwargs) # Spanning metadata too leaves only the small klass-load step # unmeasured inside the outer firstpull.load_expr total. with perf_log.perf_span("firstpull.metadata", session=session_id): @@ -569,6 +646,15 @@ async def post(self): self.write(resp) return + # The dataflow and the count exist: this is where the size policy is + # resolved. A dataflow built at the schema tier has run no stat query, so + # the policy can still keep it there; one that ran its stats in the + # constructor has nothing left to resolve (None). + stats_policy = resolve_session_policy(stats_tier, dataflow_tier, metadata["rows"], len(metadata["columns"])) + # The guards for huge sources ride with the policy: a session a host opened + # with one has them, any other has none. + source_guards = resolve_session_guards(dataflow_tier, metadata["rows"], len(metadata["columns"])) + sessions = self.application.settings["sessions"] session = sessions.get_or_create(session_id, build_dir) session.mode = "buckaroo" @@ -578,6 +664,14 @@ 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.stat_chunk_cells = stat_chunk_cells + session.stats_policy = stats_policy + if not same_expression or body.get("stats_tier") is not None: + reset_stats_controls(session) + # Before the snapshot below: it holds the config a client sorts by. + apply_source_guards(session, xorq_dataflow, source_guards) session.tele_sink = tele_sink session.xorq_dataflow = xorq_dataflow # Clear pandas-side state left by a prior /load on the same @@ -619,17 +713,16 @@ 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) + # The demand scan reads the config just stored; no query is sent. + refresh_demand_columns(session, xorq_dataflow) + + # 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 +859,15 @@ 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, session.stats_policy = "full", "inline", None + session.stat_chunk_cells = None + session.source_guards = None + reset_stats_controls(session) + 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 +897,27 @@ 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. The stats policy is resolved again for the pair. + The stored ``stat_chunk_cells`` is replayed as it is: the body has no field + for it. + A forced tier and a cost pause (``SessionState.stats_override``, + ``cost_paused``) are kept, unless the body names a ``stats_tier``, which + starts the session's stats decisions over. + 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 +940,14 @@ async def post(self, session_id): "message": "Session has no project_root — pass project_root to /load_expr first"}) return + body = self._optional_body() + stats_tier, stats_delivery, policy_error = _stats_policy_from_body( + 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: @@ -845,9 +962,16 @@ async def post(self, session_id): xorq_loading.load_project_stat_klasses(session.project_root) + xorq_loading.load_project_post_processing_klasses(session.project_root) + xorq_loading.load_project_display_klasses(session.project_root)) + dataflow_tier = dataflow_stats_tier(stats_tier, stats_delivery) xorq_dataflow = xorq_loading.XorqServerDataflow( session.expr, skip_main_serial=True, extra_klasses=extra_klasses, - **session.dataflow_kwargs) + stats_tier=dataflow_tier, stat_chunk_cells=session.stat_chunk_cells, **session.dataflow_kwargs) + # Resolved again, with the count the load took: the expression is + # reused, so there is no new one to take. + stats_policy = resolve_session_policy(stats_tier, dataflow_tier, session.metadata["rows"], + len(session.metadata["columns"])) + source_guards = resolve_session_guards(dataflow_tier, session.metadata["rows"], + len(session.metadata["columns"])) except Exception: tb = traceback.format_exc() log.error("reload_expr error session=%s: %s", session_id, tb) @@ -872,29 +996,17 @@ 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"] - - 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) + session.stats_tier = stats_tier + session.stats_delivery = stats_delivery + session.stats_policy = stats_policy + if body.get("stats_tier") is not None: + reset_stats_controls(session) + apply_source_guards(session, xorq_dataflow, source_guards) + refresh_session_snapshot(session, xorq_dataflow) + begin_stats_generation(session) + + 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..5aa7ad1cc 100644 --- a/buckaroo/server/session.py +++ b/buckaroo/server/session.py @@ -1,9 +1,14 @@ +import copy import logging import time from dataclasses import dataclass, field from typing import Any, Callable, Dict, Optional import pandas as pd + +from buckaroo.dataflow.sd_cache import split_chain_by_scope +from buckaroo.server.stats_policy import TIERS, resolve_stats_policy + # polars is optional — only used in lazy mode log = logging.getLogger("buckaroo.server.session") @@ -11,6 +16,82 @@ _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") + +# What a host may send as ``stats_tier`` on /load_expr and /reload_expr. ``auto`` +# lets the server decide from the size of the entry (``stats_policy.py``); the +# others name a tier, which the server can lower (the ceiling) but never raise. +# Not the dataflow's own tiers (``dataflow.STATS_TIERS``: ``full`` and ``schema``), +# which are what a dataflow is built at. +STATS_TIER_REQUESTS = ("auto", "full", "scalar", "schema") + +# Why a session is ``not_computed``, in ``df_meta.stats.reason``: the server's size +# rule, the host naming a tier, the cost guard pausing a run, or the ceiling +# lowering a request. (``error`` has its own reason, ``stats_failed``.) +STATS_REASONS = ("size", "host", "cost", "ceiling") + +# What a client assumes for a ``df_meta.stats`` field the server leaves out. The +# server omits a field that equals its default, so a message with no policy to +# report is the one it always was. +STATS_FIELD_DEFAULTS: Dict[str, Any] = {"auto_request": True, "requestable": ["full"], "omitted_keys": [], + "approx_keys": [], "demand_columns": []} + +# What a client assumes for the ``df_meta.sort`` and ``df_meta.search`` flags +# (rows-first p37) a message leaves out. The server sends a flag only when a +# source is too large for that request, so a session under the thresholds sends +# the message it always has. +SOURCE_GUARD_DEFAULTS: Dict[str, str] = {"sort": "enabled", "search": "enabled"} + + +def dataflow_stats_tier(stats_tier: str, stats_delivery: str) -> str: + """The tier a session's dataflow is constructed at. Only a session whose + stats run inline and whose host named no lower tier builds at the full tier; + a deferred one starts at the schema tier whatever tier it is headed for, and + so does one headed for ``scalar`` (its stats are fragments of a scalar + ``StatRun``, never assigned to the dataflow) or ``schema``. ``auto`` is + resolved against a schema-tier dataflow, which an inline session never has, + so inline ``auto`` is ``full``.""" + if stats_delivery == "deferred" or stats_tier in ("scalar", "schema"): + return "schema" + return "full" + + +# 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 snapshot has no stats, and none will be +# assigned to it: the session is headed for the schema tier, or for ``scalar``, +# whose stats reach a client that asks as ``stats_update`` fragments) 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, policy: Optional[dict] = None, + cost_paused: bool = False) -> tuple[str, Optional[str]]: + """The status, and its reason, of a session whose stats generation has just + started, from the policy pair, the policy in force and the cost guard. A + session with a policy is ``not_computed`` for ``cost`` while its automatic + stats are paused (``cost_paused``), else for the reason the policy gives when + its target is below ``full``, else ``pending``: a policy exists only for a + dataflow built at the schema tier, so a ``full`` target has its stats still + to run. With no policy (a dataflow built with its stats inline) a tier named + below ``full`` is the host's, and the stats are complete unless delivery is + deferred.""" + if policy is not None: + if cost_paused: + return "not_computed", "cost" + if policy["tier_target"] != "full": + return "not_computed", policy["reason"] + return "pending", None + if stats_tier in ("scalar", "schema"): + return "not_computed", "host" + if stats_delivery == "deferred": + return "pending", None + return "complete", None + @dataclass class SessionState: @@ -43,6 +124,63 @@ 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 what the host asked for (see + # STATS_TIER_REQUESTS) and ``stats_delivery`` how the stats get 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 /load_expr host opt-in to cutting the xorq stats batch into column + # chunks of about this many cells (``None`` is off), so that no single stat + # unit holds the loop for a whole scan. Stored with the pair above, not in + # dataflow_kwargs, and replayed by /reload_expr; the dataflow's scan guard + # still decides which sources are cut. + stat_chunk_cells: Optional[int] = None + # What ``stats_policy.resolve_stats_policy`` made of the pair and the size of + # the entry (``tier_target``, ``auto_request``, ``requestable``, ``reason``, + # ``estimate``), resolved by /load_expr and /reload_expr once the schema-tier + # dataflow and the count exist. ``None`` for a dataflow built with its stats + # inline, and for anything /load serves. ``demand_columns`` is written to it + # whenever the display config changes (``stats_wire.refresh_demand_columns``); + # later phases add ``omitted_keys`` and ``approx_keys``. ``stats_meta`` reports + # them, for the policy in force (``effective_stats_policy``). + stats_policy: Optional[dict] = None + # Whether sort and search stay on for the entry (``stats_policy.resolve_source_guards``: + # ``{"sort": "enabled" | "disabled", "search": ...}``), resolved by /load_expr and + # /reload_expr for a session a host opened with a stats policy. ``None`` for a + # dataflow built with its stats inline, and for anything /load serves, which are + # not guarded. ``build_state_message`` reports a disabled flag in ``df_meta``, + # and a sorted window of a session whose sort is disabled is refused + # (``sort_refusal``). + source_guards: Optional[dict] = None + # 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 + # What a client's forced ``stats_request`` set on the session (rows-first + # p36a). ``stats_override`` is the tier it forced above the policy's target + # (``scalar`` or ``full``) and ``stats_override_gen`` the generation it was + # made in: it raises the target of a later generation as long as the state + # is not filtered (``effective_stats_policy``). ``cost_paused`` is set when an + # automatic unit ran over the budget, and holds back further automatic + # requests until a forced one. Both survive a generation and /reload_expr, + # which is why they live here and not on a client; ``reset_stats_controls`` + # clears them. + stats_override: Optional[str] = None + stats_override_gen: int = 0 + cost_paused: bool = False + # 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,8 +218,181 @@ def touch(self) -> None: this field is the runtime escape hatch.""" +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 _filtered(session: "SessionState") -> bool: + """Whether the session's current state has a quick command (a search) on it, + which makes its stats a filtered scope's: the dataflow's ``filt`` chain is + the ``clean`` chain plus the quick commands (``sd_cache.split_chain_by_scope``).""" + scopes = split_chain_by_scope(getattr(session_dataflow(session), "operations", None) or []) + return len(scopes["filt"]) != len(scopes["clean"]) + + +def effective_stats_policy(session: "SessionState") -> Optional[dict]: + """The policy in force for the session's current state: the one resolved at + load (``session.stats_policy``, itself, when nothing changes it), or, when a + forced request raised the tier (``stats_override``), the policy that tier + resolves to for the same entry. The override never lowers the target and the + ceiling still holds for it. It applies to an unfiltered state, and to the + generation it was forced in (a filtered state's forced stats are that + state's); another filtered generation has the policy of the load. + + The thresholds are read again, as every resolution does. The reason stays the + load's while the target is below ``full``, and the demand columns are dropped: + they are the columns a ``schema`` target still wants, and a raised target + computes them.""" + policy = session.stats_policy + override = session.stats_override + if policy is None or override is None or TIERS.index(override) <= TIERS.index(policy["tier_target"]): + return policy + if session.stats_override_gen != session.stats_gen and _filtered(session): + return policy + estimate = policy["estimate"] + raised = resolve_stats_policy("xorq", "xorq_build", estimate["rows"], estimate["cols"], host_tier=override) + if TIERS.index(raised["tier_target"]) <= TIERS.index(policy["tier_target"]): + return policy + return {**raised, "reason": None if raised["tier_target"] == "full" else policy["reason"], + "estimate": dict(estimate)} + + +def reset_stats_controls(session: "SessionState") -> None: + """Forget what clients set with ``force`` and the cost guard's pause: the + session now holds another dataset, or its host named a tier again.""" + session.stats_override, session.stats_override_gen, session.cost_paused = None, 0, False + + +def restore_stats_status(session: "SessionState") -> None: + """Take a ``pending`` or ``not_computed`` session's status back to what its + policy in force and the cost guard give, without starting a generation: a + forced request has raised the target or cleared the pause, and the stats of + this generation are the same state's.""" + if session.stats_policy is not None and session.stats_status in ("pending", "not_computed"): + session.stats_status, session.stats_reason = initial_stats_status( + session.stats_tier, session.stats_delivery, effective_stats_policy(session), session.cost_paused) + + +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, the policy in force and the cost guard, 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, effective_stats_policy(session), session.cost_paused) + session.stat_runs.clear() + + +def stats_deferred_by_policy(session: "SessionState") -> bool: + """Whether the server, not the host, put the session below ``full``: the host + asked for ``auto`` or ``full`` and the policy resolved lower (a size rule, or + the ceiling). A tier the host named (``scalar``, ``schema``) is its own + choice. A client that cannot take the ``not_computed`` state is served such a + session as one headed for ``full`` (see ``stats_wire.stats_to_pull``).""" + return session.stats_status == "not_computed" and session.stats_tier in ("auto", "full") + + +def _policy_fields(policy: dict) -> dict: + """The part of a resolved policy ``df_meta.stats`` reports: ``tier_target`` and + ``estimate`` always, and the other fields only where they differ from + ``STATS_FIELD_DEFAULTS``.""" + fields: dict = {"tier_target": policy["tier_target"], "estimate": dict(policy["estimate"])} + for name in ("auto_request", "requestable"): + if policy[name] != STATS_FIELD_DEFAULTS[name]: + fields[name] = list(policy[name]) if isinstance(policy[name], list) else policy[name] + for name in ("omitted_keys", "approx_keys", "demand_columns"): + if policy.get(name): + fields[name] = list(policy[name]) + return fields + + +def stats_meta(session: "SessionState", ondemand: bool = True) -> 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 + ``full`` only once the session is complete. + + ``ondemand`` says whether the recipient advertised ``stats_ondemand``, so it + can take a ``not_computed`` state the server chose and the policy fields. Any + other client is told a session the server put below ``full`` is ``pending``, + headed for ``full``, with no policy fields. For an ondemand client a session + that is ``pending`` or ``not_computed`` adds the policy it resolved + (``_policy_fields``), unless that is an explicit ``full`` within the ceiling, + which sends the message it always has. + + ``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 (``stats_with_defaults``).""" + if session.stats_status == "complete" and session.stats_delivery != "deferred": + return None + status, reason = session.stats_status, session.stats_reason + if not ondemand and stats_deferred_by_policy(session): + status, reason = "pending", None + stats: dict = {"status": status, "tier": "full" if status == "complete" else "schema", "gen": session.stats_gen} + if reason: + stats["reason"] = reason + policy = effective_stats_policy(session) + # An explicit ``full`` within the ceiling reports no policy, unless the cost + # guard has paused it: the client then needs the target to continue. + reported = policy is not None and (session.cost_paused or not (session.stats_tier == "full" + and policy["tier_target"] == "full")) + if ondemand and reported and status in ("pending", "not_computed"): + stats.update(_policy_fields(policy)) + return stats + + +def stats_with_defaults(df_meta: Optional[dict]) -> dict: + """``df_meta.stats`` as a client reads it, with every field filled in: what a + message leaves out takes its documented default. No ``stats`` at all is what + an old server sends and means ``complete``. ``tier_target`` defaults to the + tier reached when the status is ``complete`` or ``not_computed`` and to + ``full`` otherwise (a ``pending`` or ``error`` session is headed for + ``full``). ``gen``, ``reason`` and ``estimate`` are ``None`` when absent.""" + stats = (df_meta or {}).get("stats") or {"status": "complete", "tier": "full"} + status, tier = stats["status"], stats["tier"] + out: dict = {"status": status, "tier": tier, + "tier_target": stats.get("tier_target", tier if status in ("complete", "not_computed") else "full"), + "gen": stats.get("gen"), "reason": stats.get("reason"), "estimate": stats.get("estimate")} + for name, default in STATS_FIELD_DEFAULTS.items(): + out[name] = copy.deepcopy(stats[name] if name in stats else default) + return out + + +def guards_with_defaults(df_meta: Optional[dict]) -> dict: + """The ``sort`` and ``search`` flags of a ``df_meta`` as a client reads them: + one the message leaves out is ``enabled``, which is also what an old server + sends.""" + meta = df_meta or {} + return {name: meta.get(name, default) for name, default in SOURCE_GUARD_DEFAULTS.items()} + + +def _guard_flags(session: "SessionState") -> dict: + """The flags ``df_meta`` reports for the session: the ones that are not at their default.""" + return {name: value for name, value in (session.source_guards or {}).items() + if name in SOURCE_GUARD_DEFAULTS and value != SOURCE_GUARD_DEFAULTS[name]} + + +def sort_refusal(session: "SessionState", payload_args: Any) -> Optional[dict]: + """The ``infinite_resp`` that refuses a window with a ``sort`` on a session + whose sort is disabled, or ``None`` for any other request. It carries + ``error_code: "sort_disabled"`` beside the ``error_info`` every error response + has, and no rows, so the grid sees an error for that window and no parquet frame + follows.""" + guards = session.source_guards + if not guards or guards.get("sort") != "disabled": + return None + if not isinstance(payload_args, dict) or not payload_args.get("sort"): + return None + return {"type": "infinite_resp", "key": payload_args, "length": 0, "error_code": "sort_disabled", + "error_info": "Sorting is turned off for this source: it has too many rows to sort in a request."} + + def build_state_message(session: "SessionState", metadata: dict | None = None, - search_string: str = "") -> dict: + search_string: str = "", ondemand: bool = True) -> dict: """Build the full ``initial_state`` WebSocket payload from a session. Args: @@ -94,14 +405,23 @@ def build_state_message(session: "SessionState", metadata: dict | None = None, search box. The session itself never owns this value; callers must pass the right value per recipient (typically ``handler.search_string``). + ondemand: Whether the recipient advertised ``stats_ondemand`` (see + ``stats_meta``); ``stats_wire.build_state_message_for`` passes it. Returns: A dict ready to be JSON-serialised and sent to WebSocket clients. """ + # The dataflow rebuilds df_meta wholesale, so the stats status and the guard + # flags are injected here, into a copy, rather than stored in it. + df_meta = session.df_meta + stats = stats_meta(session, ondemand=ondemand) + flags = _guard_flags(session) + if stats is not None or flags: + df_meta = {**df_meta, **flags, **({"stats": stats} if stats is not None else {})} 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..ff5682ce9 --- /dev/null +++ b/buckaroo/server/stat_run.py @@ -0,0 +1,166 @@ +"""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. + +Only the full run over every column is the session's stats: its results are the +final assignment. A run at another tier (``scalar``) or over a column group +behaves like a filtered run: its fragments go to the clients that ask and +nothing is assigned. ``stat_run_key`` keeps the full run's key as it was and +gives every other run a key of its own, so the two never meet. +""" +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, StatState, StatUnit, resolve_names, rewrite_sd + +_run_ids = itertools.count(1) + + +def stat_run_key(stats_gen: int, scope: str, tier: str = "full", columns: Optional[Sequence[Any]] = None) -> tuple: + """The key a session keeps a run under. The full run over every column is + ``(stats_gen, scope)``, as it has always been. A run at another tier or over + a column group is ``(stats_gen, scope, tier, columns)`` (``columns`` a tuple, + or ``None`` for every column), so it never takes the full run's place.""" + group = None if columns is None else tuple(columns) + if tier == "full" and group is None: + return (stats_gen, scope) + return (stats_gen, scope, tier, group) + + +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. ``state`` + is what the run describes, the stats class's own unless a tier or a column + group says otherwise (see ``StatState``). + + ``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, state: Optional[StatState] = None): + self.id = next(_run_ids) + self.stats_gen = stats_gen + self.scope = scope + self.stats = stats + self.state: StatState = stats.state if state is None else state + self.units: List[StatUnit] = stats.plan(self.state) + self.acc = stats.new_accumulator(self.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 tier(self) -> str: + return self.state.tier + + @property + def assigns(self) -> bool: + """Whether the run's results are the session's stats: the full tier over + every column. Any other run is a fragment source for clients and is + never assigned or cached as complete.""" + return self.state.tier == "full" and self.state.columns is None + + @property + def key(self) -> tuple: + return stat_run_key(self.stats_gen, self.scope, self.state.tier, self.state.columns) + + @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_policy.py b/buckaroo/server/stats_policy.py new file mode 100644 index 000000000..25b44c4a1 --- /dev/null +++ b/buckaroo/server/stats_policy.py @@ -0,0 +1,435 @@ +"""Which stats tier an entry should reach, decided from its size. + +Pure functions, so the policy can be tested without a backend. The load +handlers resolve it (``stats_wire.resolve_session_policy``), the session applies +a forced tier on top of it (``session.effective_stats_policy``) and a +``stats_request`` is judged against it. + +Tiers, lowest to highest: + +* ``schema``: dtype, ``_type``, identity keys and the row count. Never + requested automatically. +* ``scalar``: adds ``null_count``, ``min``, ``max``, ``mean``, ``std``, + ``empty_count`` and ``histogram_bins``. No histogram query, + ``value_counts``, median or (on xorq) ``distinct_count``. +* ``full``: today's stats. + +:func:`resolve_stats_policy` picks the tier. A hard ceiling is applied inside +it, so the result is the lower of the requested tier and the ceiling tier +whoever asks (a load, ``/reload_expr`` or a ``stats_request`` with ``force``). +A host tier can lower the result freely and raise it only up to the ceiling. +Eager pandas and polars resolve to ``full``: their stats sample 50,000 rows, +so the cost does not grow with the file. + +:func:`route_polars_entry` is the separate size rule for polars: an entry +above R rows belongs on xorq's ``/load_expr``, not eager ``/load``. + +:func:`demand_columns` is the demand scan: which columns a built display config +needs ``min`` and ``max`` for even when the policy computes no stats. + +:func:`resolve_source_guards` is the rule for the two requests that stay expensive +whether or not stats are computed, a sorted window and a searched one (rows-first +p37). Its thresholds are apart from the stats ones (:class:`GuardLimits`), because a +sort or a search scales with rows where the stats tiers scale with cells, and +:func:`disable_sorting` is the pass that turns sorting off in a display config. + +The thresholds are PROVISIONAL. They are the values the phase-0 measurements +proposed (one Apple M4 Pro, 115 tallyman telemetry loads, xorq stats on +parquet and CSV slices, eager polars RSS), and they rest on gaps: the +telemetry has nothing between 11.8M and 42.3M rows, nothing was measured on +the xorq side above 12M rows, and the scalar ceiling is an extrapolation. +They live in the ``DEFAULT_*`` constants below, each with the measurement it +comes from, and each has an environment override that is read on every call, +not at import. A value that is not a non-negative integer is ignored with a +warning. + +* ``BUCKAROO_STATS_FULL_AUTO_ROWS`` (12,000,000 rows) and + ``BUCKAROO_STATS_FULL_AUTO_CELLS`` (520,000,000 cells): ``full`` is chosen + automatically while both hold. +* ``BUCKAROO_STATS_SCALAR_AUTO_CELLS`` (1,000,000,000 cells): otherwise + ``scalar`` is chosen automatically up to this many cells (rows x columns). + Above it the entry stays at ``schema``. +* ``BUCKAROO_STATS_CEILING_FULL_ROWS`` (25,000,000 rows) and + ``BUCKAROO_STATS_CEILING_FULL_CELLS`` (1,000,000,000 cells): hard ceiling + on ``full``, refused above either bound, including for a forced request. +* ``BUCKAROO_STATS_CEILING_SCALAR_CELLS`` (4,000,000,000 cells): hard ceiling + on ``scalar``, in cells. Above it only ``schema`` is allowed. This number + is an extrapolation with no measurement behind it. +* ``BUCKAROO_POLARS_ROUTE_ROWS`` (8,000,000 rows): R for + :func:`route_polars_entry`. A memory threshold, set apart from the stats + ones, and sized for ``pre_limit`` False (see the constant below). + +The guard thresholds are PROVISIONAL too and read the same way (on every call, a +bad value ignored with a warning): + +* ``BUCKAROO_SORT_DISABLE_ROWS`` (25,000,000 rows): a source with more rows has + sorting turned off. +* ``BUCKAROO_SEARCH_DISABLE_ROWS`` (none): a source with more rows reports its + search as disabled. The flag is advice for a client; the server serves a search + either way. +""" +from __future__ import annotations + +import logging +import operator +import os +from dataclasses import dataclass +from typing import Any + +import pyarrow.parquet as pq + +log = logging.getLogger("buckaroo.server.stats_policy") + +# Provisional values from the phase-0 measurements; see the module docstring. +# 105 of 115 telemetry loads are at or below 12M rows (stats p90 4.4 s). Every +# load above it is one of three CSV unions (42.3M, 54.1M, 78.0M rows) at 75, +# 114 and 215 s. +DEFAULT_FULL_AUTO_ROWS: int = 12_000_000 +# Parquet 12M x 43 (516M cells) full stats took 2.9 s over 8 repetitions. The +# bound only bites on an entry wider than 43 columns at 12M rows. +DEFAULT_FULL_AUTO_CELLS: int = 520_000_000 +# The scalar class costs 1.1-1.3 ms per Mcell on a parquet scan and 3-6 ms on a +# CSV union. At the worst telemetry batch rates (30-48 ms per Mcell, scalar +# being 0.44-0.71 of the batch) that is about 20-34 s at 1.0B cells. +DEFAULT_SCALAR_AUTO_CELLS: int = 1_000_000_000 +# The ceiling on full separates the slowest in-limit load (11.8M rows, 22 s) +# from the fastest one above it (42.3M rows, 75 s). 25M rows and 1.0B cells are +# near the geometric midpoint of that gap (22M rows, 1.0B cells), and it is a +# gap in the telemetry, so the true limit could sit anywhere inside it. +DEFAULT_CEILING_FULL_ROWS: int = 25_000_000 +DEFAULT_CEILING_FULL_CELLS: int = 1_000_000_000 +# An extrapolation with no measurement behind it. The only evidence above 1.8B +# cells is the 3.36B-cell entry whose batch took 162.5 s, and scalar was not +# measured there. ``None`` means no ceiling. +DEFAULT_CEILING_SCALAR_CELLS: int | None = 4_000_000_000 +# Eager polars RSS is 0.28 GB + 0.543 GB per Mrow, and with ``pre_limit`` False +# a sorted window peaks at 0.23 + 1.62 GB per Mrow (3.0x the frame). With a +# 32 GB budget, four files resident and one window in flight, 8M rows is 27 GB +# and 10M is 33.5 GB. That figure assumes ``pre_limit`` False; main's 1M +# ``pre_limit`` windows do not copy the frame and the resident-only bound would +# be about 14M rows. +DEFAULT_POLARS_ROUTE_ROWS: int = 8_000_000 + +# Sorting a window sorts the whole source on the loop thread. At 10.8M rows x 43 +# columns that took 261-287 ms warm on parquet, and at 3M rows of CSV 326-337 ms +# (phase-0 measurements, item 4). Extrapolated linearly (nothing above 12M rows was +# measured) a 25M-row source costs about 0.7 s on parquet and 2.8 s on CSV, and the +# 42.3M, 54.1M and 78.0M entries 1.1 to 2.1 s and 4.6 to 8.5 s. 25M is the ceiling +# on full stats (the geometric midpoint of the telemetry gap between 11.8M and 42.3M +# rows), so an entry whose full stats the server refuses is one whose sort it +# refuses. A guess in the same gap, not a measurement. +DEFAULT_SORT_DISABLE_ROWS: int | None = 25_000_000 +# The server never refuses a search (plan 3 question 7), so there is no number for +# this one: the flag stays "enabled" until a host sets a threshold. +DEFAULT_SEARCH_DISABLE_ROWS: int | None = None + +# Environment variable -> StatsLimits field. +_ENV_FIELDS = {"BUCKAROO_STATS_FULL_AUTO_ROWS": "full_auto_rows", + "BUCKAROO_STATS_SCALAR_AUTO_CELLS": "scalar_auto_cells", "BUCKAROO_STATS_CEILING_FULL_ROWS": "ceiling_full_rows", + "BUCKAROO_STATS_CEILING_SCALAR_CELLS": "ceiling_scalar_cells", "BUCKAROO_POLARS_ROUTE_ROWS": "polars_route_rows", + "BUCKAROO_STATS_FULL_AUTO_CELLS": "full_auto_cells", "BUCKAROO_STATS_CEILING_FULL_CELLS": "ceiling_full_cells"} + +# Environment variable -> GuardLimits field. +_GUARD_ENV_FIELDS = {"BUCKAROO_SORT_DISABLE_ROWS": "sort_disable_rows", + "BUCKAROO_SEARCH_DISABLE_ROWS": "search_disable_rows"} + +# Lowest to highest. +TIERS = ("schema", "scalar", "full") + +# Backends whose stats cost does not grow with the file. +EAGER_BACKENDS = frozenset({"pandas", "polars"}) + + +def _env_int(name: str) -> int | None: + raw = os.environ.get(name, "").strip() + if not raw: + return None + try: + value = int(raw) + except ValueError: + value = -1 + if value < 0: + log.warning("%s=%r is not a non-negative integer; using the default", name, raw) + return None + return value + + +def _env_overrides(env_fields: dict[str, str]) -> dict[str, int]: + """The ``{field: value}`` of every variable in ``env_fields`` that is set to a + non-negative integer.""" + overrides = {} + for name, field in env_fields.items(): + value = _env_int(name) + if value is not None: + overrides[field] = value + return overrides + + +@dataclass(frozen=True) +class StatsLimits: + """The thresholds :func:`resolve_stats_policy` and + :func:`route_polars_entry` read. ``ceiling_scalar_cells=None`` means + ``scalar`` has no ceiling.""" + full_auto_rows: int = DEFAULT_FULL_AUTO_ROWS + scalar_auto_cells: int = DEFAULT_SCALAR_AUTO_CELLS + ceiling_full_rows: int = DEFAULT_CEILING_FULL_ROWS + ceiling_scalar_cells: int | None = DEFAULT_CEILING_SCALAR_CELLS + polars_route_rows: int = DEFAULT_POLARS_ROUTE_ROWS + # Added after the first five fields, so a positional construction keeps its meaning. + full_auto_cells: int = DEFAULT_FULL_AUTO_CELLS + ceiling_full_cells: int = DEFAULT_CEILING_FULL_CELLS + + @classmethod + def from_env(cls) -> StatsLimits: + """The defaults with any ``BUCKAROO_*`` overrides applied.""" + return cls(**_env_overrides(_ENV_FIELDS)) + + +@dataclass(frozen=True) +class GuardLimits: + """The thresholds :func:`resolve_source_guards` reads, in rows. ``None`` means + the flag is never turned off.""" + sort_disable_rows: int | None = DEFAULT_SORT_DISABLE_ROWS + search_disable_rows: int | None = DEFAULT_SEARCH_DISABLE_ROWS + + @classmethod + def from_env(cls) -> GuardLimits: + """The defaults with any ``BUCKAROO_*`` overrides applied.""" + return cls(**_env_overrides(_GUARD_ENV_FIELDS)) + + +def _count(name: str, value: Any) -> int: + try: + count = operator.index(value) + except TypeError: + raise TypeError(f"{name} must be an integer, got {value!r}") from None + if count < 0: + raise ValueError(f"{name} must not be negative, got {count}") + return count + + +def _size_tier(rows: int, cells: int, limits: StatsLimits) -> str: + """The tier the server picks on its own for an entry of this size.""" + if rows <= limits.full_auto_rows and cells <= limits.full_auto_cells: + return "full" + if cells <= limits.scalar_auto_cells: + return "scalar" + return "schema" + + +def _ceiling_tier(rows: int, cells: int, limits: StatsLimits) -> str: + """The highest tier any caller may reach for an entry of this size.""" + if limits.ceiling_scalar_cells is not None and cells > limits.ceiling_scalar_cells: + return "schema" + if rows <= limits.ceiling_full_rows and cells <= limits.ceiling_full_cells: + return "full" + return "scalar" + + +def resolve_stats_policy(backend: str, source_kind: str, rows: int, cols: int, bytes: int | None = None, + host_tier: str | None = None, limits: StatsLimits | None = None) -> dict[str, Any]: + """Resolve the stats tier for an entry. + + ``rows`` is the count load already took (the parquet footer's, or xorq's + cached ``_expr_count``); this function never counts anything. ``host_tier`` + is the request's ``stats_tier`` (``None`` or ``"auto"`` for the server's + own choice); a ``stats_request`` with ``force`` passes the tier it asks for + here. ``limits`` defaults to :meth:`StatsLimits.from_env`. + + Returns a JSON-serialisable dict: + + * ``tier_target``: the tier the session should reach. + * ``auto_request``: whether the client should request stats up to + ``tier_target`` on its own. False only for ``schema``. + * ``requestable``: tiers above ``tier_target`` that an on-demand request + may still be granted, in ascending order, up to the ceiling. + * ``reason``: ``None`` when ``tier_target`` is ``full``; otherwise + ``"ceiling"`` if the ceiling clipped the request, ``"host"`` if the host + asked for this tier, ``"size"`` if the server chose it. The ``"cost"`` + reason belongs to the cost guard and is never returned here. + * ``estimate``: the inputs the decision rests on (``rows``, ``cols`` and + ``bytes`` when given). + + Eager backends ignore ``host_tier``. ``source_kind`` and ``bytes`` are + accepted for the rules that will read them (a CSV size rule, and a bytes + threshold if calibration wants one); no threshold reads them yet. + ``source_kind`` must be a ``str``, its vocabulary being the caller's. + ``bytes`` is ``None`` or a non-negative integer, echoed as a plain ``int``. + A value of the wrong type raises ``TypeError`` and a negative count + ``ValueError``. + """ + if not isinstance(source_kind, str): + raise TypeError(f"source_kind must be a str, got {source_kind!r}") + rows = _count("rows", rows) + cols = _count("cols", cols) + if bytes is not None: + bytes = _count("bytes", bytes) + if host_tier == "auto": + host_tier = None + if host_tier is not None and host_tier not in TIERS: + raise ValueError(f"host_tier must be one of {TIERS} or 'auto', got {host_tier!r}") + if limits is None: + limits = StatsLimits.from_env() + + if backend in EAGER_BACKENDS: + requested = ceiling = "full" + from_host = False + else: + cells = rows * cols + ceiling = _ceiling_tier(rows, cells, limits) + from_host = host_tier is not None + requested = host_tier or _size_tier(rows, cells, limits) + + target = min(requested, ceiling, key=TIERS.index) + if target == "full": + reason = None + elif target != requested: + reason = "ceiling" + elif from_host: + reason = "host" + else: + reason = "size" + + requestable = list(TIERS[TIERS.index(target) + 1:TIERS.index(ceiling) + 1]) + estimate: dict[str, int] = {"rows": rows, "cols": cols} + if bytes is not None: + estimate["bytes"] = bytes + return {"tier_target": target, "auto_request": target != "schema", "requestable": requestable, "reason": reason, + "estimate": estimate} + + +def route_polars_entry(rows: int, cols: int, limits: StatsLimits | None = None) -> str: + """``"xorq"`` for a polars entry above R rows, ``"eager"`` otherwise. + + Eager polars holds about 6.8 GB at 12M rows and cannot hold the 78M-row + entry, so a host sends entries above R to ``/load_expr`` instead of + ``/load`` with ``backend="polars"``. R is ``limits.polars_route_rows``, + 8M rows by default. That figure assumes ``PolarsServerSampling.pre_limit`` + is False, so a sorted window copies the frame (3.0x its size at the peak); + with the 1M ``pre_limit`` on main windows do not copy it and the same + memory budget would allow about 14M rows. ``cols`` is accepted so the rule + can become a cell budget without a signature change; only ``rows`` decides + today. + """ + rows = _count("rows", rows) + _count("cols", cols) + if limits is None: + limits = StatsLimits.from_env() + return "xorq" if rows > limits.polars_route_rows else "eager" + + +def resolve_source_guards(backend: str, source_kind: str, rows: int, cols: int, + limits: GuardLimits | None = None) -> dict[str, str]: + """Whether sort and search stay on for a source of this size. + + Returns ``{"sort": ..., "search": ...}``, each ``"enabled"`` or ``"disabled"``: + a flag is ``"disabled"`` when the source has more rows than its threshold in + ``limits`` (default :meth:`GuardLimits.from_env`) and the threshold is set. + ``rows`` is the count load already took; nothing is counted here. Eager + backends always keep both, since their windows come from a bounded frame. + ``cols`` and ``source_kind`` are accepted so a rule can read them without a + signature change; only ``rows`` decides today. Inputs are checked as + :func:`resolve_stats_policy` checks them. + """ + if not isinstance(source_kind, str): + raise TypeError(f"source_kind must be a str, got {source_kind!r}") + rows = _count("rows", rows) + _count("cols", cols) + if limits is None: + limits = GuardLimits.from_env() + if backend in EAGER_BACKENDS: + return {"sort": "enabled", "search": "enabled"} + + def flag(threshold: int | None) -> str: + return "disabled" if threshold is not None and rows > threshold else "enabled" + + return {"sort": flag(limits.sort_disable_rows), "search": flag(limits.search_disable_rows)} + + +def _unsortable(column: Any) -> Any: + """A column config with ``ag_grid_specs.sortable`` false, its other specs kept.""" + if not isinstance(column, dict): + return column + specs = column.get("ag_grid_specs") + return {**column, "ag_grid_specs": {**(specs if isinstance(specs, dict) else {}), "sortable": False}} + + +def disable_sorting(display_args: Any) -> Any: + """A copy of ``df_display_args`` in which no column of a display served by + ``infinite_request`` can sort: every ``column_config`` and ``left_col_configs`` + entry of a display whose ``data_key`` is ``main`` gets ``ag_grid_specs.sortable`` + false, whatever a klass or an override set. A display with another + ``data_key`` (the summary grid) is a client-side grid and is left as it is. + + It is the last step of building the config (``XorqServerDataflow``), after the + klasses styled it and ``column_config_overrides`` were merged, so neither can + bypass it. ``display_args`` is not changed and may have any shape.""" + if not isinstance(display_args, dict): + return display_args + out = {} + for name, display in display_args.items(): + config = display.get("df_viewer_config") if isinstance(display, dict) else None + if not isinstance(config, dict) or display.get("data_key") != "main": + out[name] = display + continue + columns = {key: [_unsortable(column) for column in config[key]] + for key in ("column_config", "left_col_configs") if isinstance(config.get(key), list)} + out[name] = {**display, "df_viewer_config": {**config, **columns}} + return out + + +def demand_columns(display_args: Any, pairs: Any) -> list[str]: + """The columns a display config reads ``histogram_bins`` of: the + ``val_column`` of every ``color_map_config`` whose ``color_rule`` is + ``color_map``, in any display of ``display_args`` (``df_display_args``). The + scan reads the config alone, so it sends no query. It covers the rules a host + put in the column overrides and the ones a klass adds at style time, since + both are in the config once it is built. + + ``pairs`` are the ``(original, rewritten)`` names of the table's columns. A + ``val_column`` may be either, and a rewritten name wins over an original + that looks like one, as the client reads it; one that is neither (or not a + string) names no column and is dropped. A ``color_map`` rule with no + ``val_column`` reads nothing, and neither does a categorical rule or a + tooltip. The result is the rewritten names, once each, in the table's order; + ``display_args`` is not changed and may have any shape.""" + wanted = set() + for display in display_args.values() if isinstance(display_args, dict) else (): + config = display.get("df_viewer_config") if isinstance(display, dict) else None + columns = config.get("column_config") if isinstance(config, dict) else None + for column in columns if isinstance(columns, list) else (): + rule = column.get("color_map_config") if isinstance(column, dict) else None + val_column = rule.get("val_column") if isinstance(rule, dict) else None + if isinstance(val_column, str) and rule.get("color_rule") == "color_map": + wanted.add(val_column) + by_name = {**{orig: new for orig, new in pairs}, **{new: new for _orig, new in pairs}} + named = {by_name[name] for name in wanted if name in by_name} + return [new for _orig, new in pairs if new in named] + + +def probe_dtypes(obj: Any) -> dict[str, str]: + """Column name to dtype string, from the schema alone. + + Takes a pandas or polars frame (a ``LazyFrame`` is not collected) or a xorq + expression. No row is read and no query runs. + """ + # Polars first: reading ``LazyFrame.dtypes`` resolves the plan and warns. + if hasattr(obj, "collect_schema"): + pairs = obj.collect_schema().items() + elif callable(getattr(obj, "schema", None)): + pairs = obj.schema().items() + elif hasattr(getattr(obj, "dtypes", None), "items"): + pairs = obj.dtypes.items() + else: + raise TypeError(f"cannot read a schema from {type(obj).__name__}") + return {str(name): str(dtype) for name, dtype in pairs} + + +def probe_parquet_rows(source: Any) -> int: + """A parquet file's row count, from its footer. + + ``source`` is a path or a binary file object. Only the footer metadata is + read, so the cost does not grow with the file (0.4 ms on 4M rows). A + single file only; a dataset directory is not handled. + """ + return int(pq.read_metadata(source).num_rows) diff --git a/buckaroo/server/stats_wire.py b/buckaroo/server/stats_wire.py new file mode 100644 index 000000000..c0a315650 --- /dev/null +++ b/buckaroo/server/stats_wire.py @@ -0,0 +1,836 @@ +"""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. + +A session also carries the policy resolved at load (``stats_policy.py``). A +target the server chose below ``full`` (a size rule, or the ceiling on a host +that asked for ``auto`` or ``full``) is applied only for a client that +advertised ``?caps=stats_update,stats_ondemand``: it gets the schema tier, +``df_meta.stats.status == "not_computed"`` and the policy fields. A client with +``stats_update`` only is told the session is ``pending`` and pulls the stats as +above, and one with no caps gets them synchronously, as for any deferred +session. A tier the host named below ``full`` (``scalar``, ``schema``) is its +choice and reaches every client as ``not_computed``. ``stats_to_pull`` decides, +per client, whether a session has stats left to compute. + +A target of ``scalar`` (``serves_scalar_tier``) can be pulled by the client that +advertised both bits: its ``stats_request`` runs the units of a scalar ``StatRun`` +and is answered with ``stats_update`` messages whose tier is ``scalar``. Nothing +is assigned. The run's fragments go to the clients that ask, as a filtered run's +would, and the dataflow, the session snapshot, ``summary_stats_cache`` and the +status stay as they were, so the scalar stats are never served as the complete +ones. Only the full run over every column reaches the final assignment +(``StatRun.assigns``). + +That client can also say what it wants (rows-first p36a). ``stats_request`` takes +``tier`` (``scalar`` or ``full``), ``force`` and, with a ``tier``, ``columns`` that +scope the run to those columns (a run that is never assigned, like the scalar one). +The server judges every such request itself, in this order: a tier the ceiling +refuses for the cells asked for is answered ``stats_update {final: true, status: +"not_computed", reason: "ceiling"}`` and runs nothing; a whole-table tier below the +target, or above it without ``force``, is ``not_requestable``; while the cost guard +has paused the session, a request without ``force`` is answered with reason +``cost``. A ``force`` above the target sets the session's ``stats_override`` +(``session.effective_stats_policy``) and clears the pause. An automatic request, +one without ``force``, that ran a unit over ``STATS_COST_BUDGET_S`` and left units +to run pauses the session (``cost_paused``): the status is ``not_computed`` for +``cost`` until a ``force`` request. The fields mean nothing to any other client, +or on a session with no policy. The demand scan (``stats_policy.demand_columns``) +names the columns the display config's ``color_map`` rules read, and +``df_meta.stats.demand_columns`` carries them to a client, which asks for them as +a scoped scalar request. + +A session opened with a stats policy also resolves the guards for huge sources +(``resolve_session_guards``, rows-first p37): above the sort threshold the dataflow +turns sorting off in its display config (``apply_source_guards``) and a sorted +window is refused (``session.sort_refusal``). Neither depends on the client or on +what stats the session has. + +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 dataclasses +import hashlib +import json +import logging +import time +import traceback +from dataclasses import dataclass +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, effective_stats_policy, restore_stats_status, session_dataflow, + stats_deferred_by_policy) +from buckaroo.server.stat_run import StatCursor, StatRun, stat_run_key +from buckaroo.server.stats_policy import TIERS, demand_columns, resolve_source_guards, resolve_stats_policy + +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 second capability bit: the client also renders ``not_computed``, honours +# ``auto_request`` and sends ``tier`` and ``force`` on ``stats_request``. It counts +# only together with ``stats_update``, which it builds on. +STATS_ONDEMAND_CAP = "stats_ondemand" + +# 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 + +# The longest an automatic unit may take before the cost guard pauses the +# session, in seconds. PROVISIONAL: a unit cannot be cut short, so this bounds +# how long the loop is held by one unit that nobody asked for. The number is the +# tallyman client's base timeout (10 s), not a measurement of units. For scale: +# the largest unit on parking_2017 (10.8M rows x 43 columns), the scalar batch, +# takes 0.9 to 1.0 s, and the slowest in-limit telemetry load (an 11.8M-row CSV +# union) took 22 s for all of its units together. +STATS_COST_BUDGET_S = 10.0 + +# The tiers a ``stats_request`` can name. ``schema`` is what a session has until +# something is computed, so there is nothing to request at it. +REQUEST_TIERS = ("scalar", "full") + + +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 client_has_ondemand(client: Any) -> bool: + """Whether a WebSocket handler advertised ``stats_update`` and + ``stats_ondemand``, so a session the server put below ``full`` is applied + for it as such.""" + return client_has_cap(client, STATS_UPDATE_CAP) and client_has_cap(client, STATS_ONDEMAND_CAP) + + +def stats_to_pull(session: SessionState, client: Any) -> bool: + """Whether the session has stats left to compute from this client's side: it + is ``pending``, or the server put it below ``full`` and the client cannot + take that (``stats_deferred_by_policy``). A client with ``stats_update`` + pulls them with ``stats_request``, one with no caps gets them at connect.""" + return session.stats_status == "pending" or (stats_deferred_by_policy(session) and not client_has_ondemand(client)) + + +def serves_scalar_tier(session: SessionState, client: Any) -> bool: + """Whether a ``stats_request`` from ``client`` is answered with the scalar + tier: the client advertised both bits, so it takes tiers, and the session has + nothing computed (``not_computed``) and a policy target of ``scalar``, + whether the size rule, the host or the ceiling put it there, or a forced + request raised it (``effective_stats_policy``). Any other client of such a + session is served as ``stats_to_pull`` says.""" + policy = effective_stats_policy(session) + return (client_has_ondemand(client) and session.stats_status == "not_computed" and policy is not None + and policy["tier_target"] == "scalar") + + +def resolve_session_policy(stats_tier: str, dataflow_tier: str, rows: int, cols: int) -> Optional[dict]: + """The policy for a session the handler has just built: ``stats_tier`` is the + host's request, ``dataflow_tier`` the tier its dataflow was built at, and + ``rows`` and ``cols`` the size load already took (the cached count, so no + query runs). ``None`` when the dataflow ran its stats in the constructor + (``full``): there is nothing left to defer or refuse.""" + if dataflow_tier != "schema": + return None + return resolve_stats_policy("xorq", "xorq_build", rows, cols, host_tier=stats_tier) + + +def resolve_session_guards(dataflow_tier: str, rows: int, cols: int) -> Optional[dict]: + """The guards for a session the handler has just built, from the size load took + (``rows`` and ``cols``, so no query runs): ``{"sort": ..., "search": ...}``, each + ``"enabled"`` or ``"disabled"`` (``stats_policy.resolve_source_guards``). ``None`` + unless the dataflow was built at the schema tier, which is to say unless the host + opened the session with a stats policy, as ``resolve_session_policy`` does: a + session built with its stats inline is not guarded.""" + if dataflow_tier != "schema": + return None + return resolve_source_guards("xorq", "xorq_build", rows, cols) + + +def apply_source_guards(session: SessionState, dataflow: Any, guards: Optional[dict]) -> None: + """Record ``guards`` on the session and set the dataflow's sort switch to match, + which rebuilds its display config when the switch moves. Called before the + session snapshot is taken from the dataflow, since the config in the snapshot is + the one a client sorts by.""" + session.source_guards = guards + dataflow.set_sort_enabled(guards is None or guards["sort"] != "disabled") + + +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. The demand + columns follow the config the snapshot now holds.""" + 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} + refresh_demand_columns(session, dataflow) + + +def refresh_demand_columns(session: SessionState, dataflow: Any) -> None: + """Put the columns the session's display config wants min and max for in + ``session.stats_policy["demand_columns"]``, where ``df_meta.stats`` reports + them: the demand scan (``stats_policy.demand_columns``) over the snapshot's + config, read from the config alone with no query. Only a ``schema`` target + has demand, since a higher one computes those columns anyway. The key is + absent when there are none.""" + policy = session.stats_policy + if policy is None: + return + columns: list = [] + if policy["tier_target"] == "schema" and dataflow.processed_df is not None: + columns = demand_columns(session.df_display_args, old_col_new_col(dataflow.processed_df)) + if columns: + policy["demand_columns"] = columns + else: + policy.pop("demand_columns", None) + + +def start_stat_run(session: SessionState, scope: str = "raw", tier: str = "full", + columns: Optional[Sequence[Any]] = None) -> Optional[StatRun]: + """The ``StatRun`` of the session's current generation for ``scope``, ``tier`` + and column group: the one it holds, or a new one planned from the dataflow + and stored on the session under ``stat_run_key``. Planning sends no query. + The run analyzes the frame ``assign_full_stats`` does, so running every unit + of the full run gives the stats that call computes whole. A run at another + tier, or over a column group (``columns``, original names), is never + assigned (``StatRun.assigns``). ``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 = stat_run_key(session.stats_gen, scope, tier, columns) + run = session.stat_runs.get(key) + if run is None: + stats = dataflow.build_stats(dataflow.processed_df, run=False) + state = dataclasses.replace(stats.state, tier=tier, columns=None if columns is None else tuple(columns)) + run = StatRun(session.stats_gen, scope, stats, state) + 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, + timings: Optional[list] = 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. ``timings``, when given, + gets the seconds each unit took (the run's own timer), for the cost guard.""" + 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 + spent = run.elapsed_s + 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") + if timings is not None: + timings.append(run.elapsed_s - spent) + 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). Only a run that + assigns has them: a scalar or column-scoped run is a part of the stats, and + caching it as the whole would serve it as complete.""" + if not run.assigns: + raise ValueError(f"a {run.tier} run over {'every column' if run.state.columns is None else 'some columns'} " + "is not the session's stats and cannot be assigned") + 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 (or one the server put below + ``full``, for a client that cannot take that) 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 not (session.stats_status == "pending" or stats_deferred_by_policy(session)) 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 deferred session that + has stats to compute for it (``stats_to_pull``) gets them run first, so its + message is complete; a capable client gets the session snapshot as it is + (stats-free while the stats are to be pulled) and pulls the rest. A client + that also advertised ``stats_ondemand`` is told the session as the server + resolved it (``not_computed`` with the policy fields); the others are told + ``pending`` where the server chose a target below ``full``. 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 stats_to_pull(session, client) + 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 stats_to_pull(session, client) else None) + return build_state_message(session, metadata=metadata, search_string=getattr(client, "search_string", ""), + ondemand=client_has_ondemand(client)) + + +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, tier: str = "full", columns: Optional[Sequence[Any]] = None) -> Optional[StatRun]: + """The session's ``StatRun`` at ``tier`` (over ``columns``, original names, + when a request scoped it) 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). For + the full tier the whole-run path then decides what the stats are.""" + try: + return start_stat_run(session, tier=tier, columns=columns) + 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, + timings=info.setdefault("timings", [])) + 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": "full", + "final": False, "remaining": run.remaining, "payload": partial_payload(dataflow, run, fragments), + "elapsed_ms": _elapsed_ms(started)} + + +def _serve_run(session: SessionState, client: Any, prefer: Sequence[str], incremental: bool, stats_gen: Any, + scope: Any, started: float, info: dict, tier: str = "scalar", + columns: Optional[Sequence[Any]] = None) -> dict: + """The reply to a ``stats_request`` served from a run that is not the + session's stats: the ``scalar`` run over the whole table, or a run over named + ``columns`` (original names) at ``tier``. A ``stats_update`` with that tier + holding the fragments this client has not seen, ``final`` once the run is + done; the final one also says what the session still is (``status``, and the + ``reason``), since this run did not change it. An incremental request runs + units for the time budget, as the full run's does; any other runs every unit + still to run. + + Nothing is assigned. The run stays on the session for the generation, so a + client that asks later is caught up from the fragments with no unit run, and + the status, the snapshot and the caches are as they were. A unit that raises + aborts the request and the run (it is not retried) but not the session, whose + full tier is still open.""" + run = _plan_run(session, tier, columns) + if run is None or run.status == "error": + return _aborted(stats_gen, scope, "error", session) + cursor = getattr(client, "stats_cursor", None) or StatCursor() + if cursor.caught_up(run): + try: + info["units"] = run_units(run, STATS_BUDGET_S if incremental else None, prefer, + session_id=session.session_id, timings=info.setdefault("timings", [])) + except Exception: + log.error("%s stat unit failed session=%s stats_gen=%s: %s", run.tier, session.session_id, + session.stats_gen, traceback.format_exc()) + return _aborted(stats_gen, scope, "error", session) + fragments = cursor.take(run) + reply = {"type": "stats_update", "stats_gen": stats_gen, "scope": scope, "tier": run.tier, + "final": run.status != "pending", "remaining": run.remaining, + "payload": partial_payload(session_dataflow(session), run, fragments), "elapsed_ms": _elapsed_ms(started)} + if reply["final"]: + reply["status"] = session.stats_status + if session.stats_reason: + reply["reason"] = session.stats_reason + return reply + + +@dataclass(frozen=True) +class TierRequest: + """The part of a ``stats_request`` an ondemand client's ``tier``, ``force`` + and ``columns`` say. ``tier`` is ``None`` for the session's own target; + ``columns`` (original names, in frame order) is set only for a request that + scopes the run, which needs a ``tier``.""" + tier: Optional[str] + force: bool + columns: Optional[tuple] + + +def tier_fields_apply(session: SessionState, client: Any) -> bool: + """Whether ``tier``, ``force`` and a scoping ``columns`` mean anything for + this request: the client advertised both bits, and the session has a policy + to enforce them against. For any other client they are ignored.""" + return client_has_ondemand(client) and session.stats_policy is not None + + +def _tier_request(msg: dict, dataflow: Any) -> Optional[TierRequest]: + """The request's ``TierRequest``, or ``None`` when a field is not one the + server can read: a ``tier`` other than ``scalar`` or ``full``, a ``force`` + that is not a boolean, or ``columns`` (with a ``tier``) that is not a + non-empty list of the grid's column names. Without a ``tier``, ``columns`` is + only the hint it always was.""" + tier, force, columns = msg.get("tier"), msg.get("force"), msg.get("columns") + force = False if force is None else force + if (tier is not None and tier not in REQUEST_TIERS) or not isinstance(force, bool): + return None + if tier is None or columns is None: + return TierRequest(tier, force, None) + pairs = old_col_new_col(dataflow.processed_df) + if not (isinstance(columns, list) and columns and all(isinstance(c, str) for c in columns) + and set(columns) <= {rewritten for _orig, rewritten in pairs}): + return None + wanted = set(columns) + return TierRequest(tier, force, tuple(orig for orig, rewritten in pairs if rewritten in wanted)) + + +def _refused(session: SessionState, stats_gen: Any, scope: Any, tier: str, reason: str, started: float, + info: dict) -> dict: + """The reply to a request the server will not run (reason ``ceiling`` or + ``cost``): a final ``stats_update`` with no payload that says the session has + no more stats than it had, so a client can show why and stop asking.""" + info["refused"] = reason + return {"type": "stats_update", "stats_gen": stats_gen, "scope": scope, "tier": tier, "final": True, + "remaining": 0, "status": "not_computed", "reason": reason, "elapsed_ms": _elapsed_ms(started)} + + +def _gate_request(session: SessionState, asked: TierRequest, stats_gen: Any, scope: Any, started: float, + info: dict) -> Optional[dict]: + """Judge a tier request against the policy in force, in the order the module + docstring gives: the ceiling for the cells asked for (``resolve_stats_policy`` + with the tier as the host's, the same function load uses), then, for a + whole-table request, whether the tier is the target or above it with + ``force``, then the cost pause. Returns the reply that refuses it, or + ``None`` when it may run. The count is the one load took.""" + policy = effective_stats_policy(session) + estimate, target = policy["estimate"], policy["tier_target"] + scoped = asked.columns is not None + tier = asked.tier or target + if not scoped and tier == "schema": + return _aborted(stats_gen, scope, "not_requestable", session) + cols = len(asked.columns) if scoped else estimate["cols"] + if resolve_stats_policy("xorq", "xorq_build", estimate["rows"], cols, host_tier=tier)["tier_target"] != tier: + return _refused(session, stats_gen, scope, tier, "ceiling", started, info) + if not scoped: + rank, target_rank = TIERS.index(tier), TIERS.index(target) + if rank < target_rank or (rank > target_rank and not asked.force): + return _aborted(stats_gen, scope, "not_requestable", session) + if session.cost_paused and not asked.force: + return _refused(session, stats_gen, scope, tier, "cost", started, info) + return None + + +def _apply_force(session: SessionState, client: Any, asked: TierRequest) -> None: + """What a ``force`` request does to the session before it runs: clear the + cost pause, and for a whole-table tier above the target, record the override + so the target (and the status) follow it for this and later unfiltered + generations. The client that forced a whole-table ``full`` run, whether it + raised the target or continued a paused run to it, holds the schema-tier + display config its ``not_computed`` frame carried (a frame that is not + ``pending`` records no digest), so its digest is kept for the final reply to + compare.""" + if not asked.force: + return + session.cost_paused = False + target = effective_stats_policy(session)["tier_target"] + tier = asked.tier or target + whole_table = asked.columns is None + if whole_table and TIERS.index(tier) > TIERS.index(target): + session.stats_override, session.stats_override_gen = tier, session.stats_gen + if whole_table and tier == "full" and getattr(client, "display_args_hash", None) is None: + client.display_args_hash = display_args_hash(session.df_display_args) + restore_stats_status(session) + + +def _pause_if_slow(session: SessionState, reply: dict, info: dict) -> None: + """The cost guard. An automatic request (one with no ``force``, from a client + the fields apply to) whose reply leaves units to run, and which ran a unit + over ``STATS_COST_BUDGET_S``, pauses the session: the status is + ``not_computed`` for ``cost``, and the next automatic request is refused until + a ``force`` one clears it. A request that finished the run holds nothing back, + and a forced one is the user's own choice.""" + if not info.get("automatic") or reply["type"] != "stats_update" or reply["final"]: + return + if max(info.get("timings") or [0.0]) > STATS_COST_BUDGET_S: + session.cost_paused = True + session.stats_status, session.stats_reason = "not_computed", "cost" + info["paused"] = True + + +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 tier_fields_apply(session, client): + asked = _tier_request(msg, dataflow) + if asked is None: + return _aborted(stats_gen, scope, "bad_request", session) + # A complete session answers from the dataflow, and a failed one is an + # error below, whatever the request names. + if session.stats_status in ("pending", "not_computed"): + refusal = _gate_request(session, asked, stats_gen, scope, started, info) + if refusal is not None: + return refusal + info["automatic"] = not asked.force + _apply_force(session, client, asked) + if asked.columns is not None: + return _serve_run(session, client, prefer, bool(incremental), stats_gen, scope, started, info, + tier=asked.tier, columns=asked.columns) + if serves_scalar_tier(session, client): + return _serve_run(session, client, prefer, bool(incremental), stats_gen, scope, started, info) + to_pull = stats_to_pull(session, client) + if session.stats_status == "not_computed" and not to_pull: + return _aborted(stats_gen, scope, "not_requestable", session) + if to_pull and incremental: + reply = _serve_units(session, client, prefer, stats_gen, scope, started, info) + if reply is not None: + return reply + if to_pull: + 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. On a session whose target is + ``scalar`` an ondemand client's request is answered as ``_serve_run`` + says: ``stats_update`` messages with ``tier: "scalar"`` and nothing assigned. + ``tier``, ``force`` and ``columns`` are read for an ondemand client of a + session with a policy (see the module docstring), and ``elapsed_ms`` is in + every ``stats_update``. + + 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) + _pause_if_slow(session, reply, info) + 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"]) + if "refused" in info: + span.set_attr(refused=info["refused"]) + if info.get("paused"): + span.set_attr(paused=True) + return reply diff --git a/buckaroo/server/websocket_handler.py b/buckaroo/server/websocket_handler.py index 2232211c7..39d9254b7 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, sort_refusal +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)) @@ -209,6 +220,11 @@ def _dispatch(pa): # each backend can apply the filter in its native # expression layer. search = self.search_string or "" + # A source too large to sort in a request refuses the window before any + # query runs, the second window of a request included. + refusal = sort_refusal(session, pa) + if refusal is not None: + return refusal, b"" if session.mode == "lazy" and session.ldf is not None: return handle_infinite_request_lazy(session.ldf, session.orig_to_rw, session.rw_to_orig, session.metadata.get("rows", 0), pa) diff --git a/buckaroo/server/xorq_loading.py b/buckaroo/server/xorq_loading.py index 60a9226cf..286dc7847 100644 --- a/buckaroo/server/xorq_loading.py +++ b/buckaroo/server/xorq_loading.py @@ -14,11 +14,15 @@ import os import traceback import uuid +import weakref +from collections import OrderedDict from pathlib import Path import pyarrow.parquet as pq +from buckaroo.customizations.xorq_commands import search_expr from buckaroo.server.git_state_guard import install_git_state_guard +from buckaroo.server.stats_policy import disable_sorting from buckaroo.server.window import clamp_window from buckaroo.serialization_utils import make_infinite_resp from buckaroo.xorq_buckaroo import ( @@ -64,23 +68,50 @@ 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``). + + ``sort_enabled`` is whether the grid served by ``infinite_request`` may sort. + The server turns it off (``set_sort_enabled``) for a source with too many rows + to sort in a request (``stats_policy.resolve_source_guards``); the config is + then built with ``stats_policy.disable_sorting`` as its last step, so no + klass or override can turn sorting back on, and every rebuild keeps it. """ + sort_enabled = True sampling_klass = XorqInfiniteSampling autocleaning_klass = XorqAutocleaning autoclean_conf = (NoCleaningConfXorq,) 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) + def set_sort_enabled(self, enabled: bool) -> None: + """Turn sorting on or off in the display config, rebuilding it now when + the switch moves.""" + if enabled == self.sort_enabled: + return + self.sort_enabled = enabled + _unused, processed_df, merged_sd = self.widget_args_tuple + if processed_df is not None: + self.df_display_args = self._build_df_display_args(processed_df, merged_sd) + + def _build_df_display_args(self, processed_df, merged_sd): + display_args = super()._build_df_display_args(processed_df, merged_sd) + return display_args if self.sort_enabled else disable_sorting(display_args) + def load_expr_build_dir(build_dir: str, cache_dir=None): """Rehydrate an ibis expression from a xorq build directory. @@ -556,6 +587,40 @@ def _compile_project_display(path: Path, ColAnalysis, *extra_bases): return found +# The searched expression of each (base expression, term). ``search_expr`` builds +# a new expression on every call and ``_expr_count`` caches by expression object, so +# every request for a searched window counted its rows again (0.5 to 1 s on 10.8M +# rows). Holding the expression makes the second request a hit in ``_expr_count``'s +# cache, which still does not cache a count that failed. Keyed weakly by the base +# expression, the dataflow's ``processed_df``, which a state change replaces, so an +# entry lasts as long as its base; a base keeps at most this many terms, the least +# recently used going first. +_SEARCHED_EXPRS_PER_BASE = 16 +_searched_exprs: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + + +def _searched_expr(base, term): + """``search_expr(base, term)``: the same object each time for the same pair.""" + if not term: + return base + terms = _searched_exprs.get(base) + expr = terms.get(term) if terms is not None else None + if expr is not None: + terms.move_to_end(term) + return expr + expr = search_expr(base, term) + if expr is base: + # No string column to search: there is nothing to hold, and the base held as + # its own value would never be freed. + return base + if terms is None: + terms = _searched_exprs[base] = OrderedDict() + terms[term] = expr + if len(terms) > _SEARCHED_EXPRS_PER_BASE: + terms.popitem(last=False) + return expr + + def handle_infinite_request_xorq(xorq_dataflow: XorqServerDataflow, payload_args: dict, search_string: str = "") -> tuple[dict, bytes]: """Drive one infinite_request window against a xorq expression. @@ -567,13 +632,12 @@ def handle_infinite_request_xorq(xorq_dataflow: XorqServerDataflow, delegates to ``window_to_parquet`` and returns the ``(json_msg, parquet_bytes)`` pair the WebSocket handler ships as a text + binary frame pair (matching the pandas/polars paths).""" - from buckaroo.customizations.xorq_commands import search_expr _unused, processed_df, merged_sd = xorq_dataflow.widget_args_tuple if processed_df is None: return ({"type": "infinite_resp", "key": payload_args, "length": 0}, b"") try: - filtered_expr = search_expr(processed_df, search_string) if search_string else processed_df + filtered_expr = _searched_expr(processed_df, search_string) sort = payload_args.get("sort") sort_col = None 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..45fbd3078 100644 --- a/tests/unit/server/test_data_loading_polars.py +++ b/tests/unit/server/test_data_loading_polars.py @@ -9,15 +9,44 @@ * ``.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 stat_run as stat_run_mod +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 +118,826 @@ 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}}] + + def test_the_full_run_over_every_column_is_the_run_that_assigns(self, backend): + run, _dataflow = _stat_run(backend) + assert (run.tier, run.assigns) == ("full", True) + + def test_a_run_over_a_column_group_has_its_own_key_and_assigns_nothing(self, backend): + dataflow = _build_dataflow(backend, _frame(backend, _UNTIED_DATA)) + stats = dataflow.build_stats(dataflow.processed_df, run=False) + run = StatRun(4, "raw", stats, state=dataclasses.replace(stats.state, columns=("price",))) + assert run.key == (4, "raw", "full", ("price",)) + assert (run.tier, run.assigns) == ("full", False) + assert [u.columns for u in run.units] == [("price",)] + while run.run_next() is not None: + pass + assert list(run.acc.sd()) == ["price"] + + def test_the_key_of_a_run_names_its_tier_and_group_unless_it_is_the_full_run(self): + key = stat_run_mod.stat_run_key + assert key(4, "raw") == key(4, "raw", "full", None) == (4, "raw") + assert key(4, "raw", "scalar") == (4, "raw", "scalar", None) + assert key(4, "raw", "full", ["b", "a"]) == (4, "raw", "full", ("b", "a")) + + def test_a_pandas_or_polars_run_has_only_the_full_tier(self, backend): + dataflow = _build_dataflow(backend, _frame(backend, _UNTIED_DATA)) + stats = dataflow.build_stats(dataflow.processed_df, run=False) + with pytest.raises(ValueError, match="full tier"): + StatRun(4, "raw", stats, state=dataclasses.replace(stats.state, tier="scalar")) + + +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_compare.py b/tests/unit/server/test_load_compare.py index ba9be9198..a0909feec 100644 --- a/tests/unit/server/test_load_compare.py +++ b/tests/unit/server/test_load_compare.py @@ -8,6 +8,7 @@ import tornado.testing from buckaroo.server.app import make_app as _make_app +from buckaroo.server.session import build_state_message pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="Temp file locking prevents cleanup on Windows") @@ -114,6 +115,53 @@ def test_eqs_reports_diffs(self): os.unlink(f1.name) os.unlink(f2.name) + def test_load_compare_resolves_to_full_and_clears_a_stats_policy(self): + """A compare session is a viewer with no dataflow, so no stats are + deferred or refused: a policy that an earlier /load_expr left on the + session is dropped (rows-first p33).""" + session = self._app.settings["sessions"].create("cmp-policy", "") + session.stats_tier, session.stats_delivery = "auto", "deferred" + session.stats_policy = {"tier_target": "schema", "reason": "size"} + df1 = pd.DataFrame({"id": [1, 2], "score": [100, 200]}) + df2 = pd.DataFrame({"id": [1, 2], "score": [100, 999]}) + + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f1, \ + tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f2: + _write_df(df1, f1.name) + _write_df(df2, f2.name) + try: + resp = self._post_compare({"session": "cmp-policy", "path1": f1.name, "path2": f2.name, + "join_columns": ["id"]}) + self.assertEqual(resp.code, 200) + finally: + os.unlink(f1.name) + os.unlink(f2.name) + self.assertEqual((session.stats_tier, session.stats_delivery), ("full", "inline")) + self.assertIsNone(session.stats_policy) + self.assertEqual(session.stats_status, "complete") + self.assertNotIn("stats", build_state_message(session)["df_meta"]) + + def test_load_compare_drops_a_stat_chunk_cells_left_by_load_expr(self): + """A compare session runs no xorq stats batch, so the count a prior + /load_expr stored on the session is dropped (rows-first s6).""" + session = self._app.settings["sessions"].create("cmp-chunk-cells", "") + session.stat_chunk_cells = 60_000_000 + df1 = pd.DataFrame({"id": [1, 2], "score": [100, 200]}) + df2 = pd.DataFrame({"id": [1, 2], "score": [100, 999]}) + + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f1, \ + tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f2: + _write_df(df1, f1.name) + _write_df(df2, f2.name) + try: + resp = self._post_compare({"session": "cmp-chunk-cells", "path1": f1.name, "path2": f2.name, + "join_columns": ["id"]}) + self.assertEqual(resp.code, 200) + finally: + os.unlink(f1.name) + os.unlink(f2.name) + self.assertIsNone(session.stat_chunk_cells) + def test_missing_fields(self): resp = self._post_compare({"session": "x", "path1": "/a"}) self.assertEqual(resp.code, 400) diff --git a/tests/unit/server/test_load_expr.py b/tests/unit/server/test_load_expr.py index bdbb47db6..ad99f7e0d 100644 --- a/tests/unit/server/test_load_expr.py +++ b/tests/unit/server/test_load_expr.py @@ -1,17 +1,25 @@ """End-to-end tests for POST /load_expr — server load path for XorqBuckarooInfiniteWidget over a xorq/ibis expression.""" +import dataclasses +import datetime +import gc import io import json import os import shutil import sys import tempfile +import time +import weakref +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 @@ -24,9 +32,24 @@ from xorq.common.utils.graph_utils import replace_nodes, walk_nodes # noqa: E402 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 xorq.vendor.ibis.expr import operations as ops # noqa: E402 +from xorq.vendor.ibis.expr.types.core import Expr # 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 session as session_mod # 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 StatCursor, 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 +933,4114 @@ 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_scan_dir(data_root, builds_root, joined=False): + """Build the twelve-row, six-column parquet read of ``_wide_parquet_expr`` to + ``builds_root`` and return the build path. ``joined`` builds a join of it + with a second parquet read instead, an expression the batch split refuses.""" + expr = _wide_parquet_expr(data_root, "left" if joined else "wide") + if joined: + pd.DataFrame({"n0": [float(i) for i in range(12)], "extra": list(range(12))}).to_parquet( + data_root / "right.parquet") + expr = expr.join(xo.deferred_read_parquet(str(data_root / "right.parquet")), "n0") + return str(xo.build_expr(expr, builds_dir=builds_root)) + + +@contextmanager +def _spy_units(): + """Record the id of every unit ``XorqStatPipeline`` runs while the block runs.""" + ran = [] + original = XorqStatPipeline.run + + def spy(pipeline, unit, acc): + ran.append(unit.id) + return original(pipeline, unit, acc) + + with patch.object(XorqStatPipeline, "run", spy): + yield ran + + +def _batch_ids(ran): + return [unit_id for unit_id in ran if unit_id.startswith("batch")] + + +class TestLoadExprStatChunkCells(tornado.testing.AsyncHTTPTestCase): + """``stat_chunk_cells`` on POST /load_expr (rows-first s6): the host's opt-in + to cutting the xorq stats batch into column chunks, so that no single stat + unit holds the loop for a whole scan. It is stored beside the stats policy + fields, replayed by /reload_expr and kept out of the has_config tuple. The + pipeline's scan guard still decides which sources are cut. The fixture has + twelve rows and six columns, so 36 cells is three columns per chunk.""" + + def get_app(self): + return make_app() + + def setUp(self): + super().setUp() + data_root = Path(tempfile.mkdtemp()) + self.builds_root = tempfile.mkdtemp() + self.project_root = tempfile.mkdtemp() + for root in (data_root, self.builds_root, self.project_root): + self.addCleanup(shutil.rmtree, root, ignore_errors=True) + self.scan_build = _build_scan_dir(data_root, self.builds_root) + self.join_build = _build_scan_dir(data_root, self.builds_root, joined=True) + # Zero runs exactly one unit per incremental request. + budget = patch.object(stats_wire, "STATS_BUDGET_S", 0) + budget.start() + self.addCleanup(budget.stop) + 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 _post_load(self, sid, build_dir=None, **body): + return await _post(self.get_http_port(), "/load_expr", + {"session": sid, "build_dir": build_dir or self.scan_build, **body}) + + async def _load(self, sid, build_dir=None, **body): + resp = await self._post_load(sid, build_dir, **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) + + async def _pull(self, sid, build_dir=None, **body): + """Load ``sid`` with deferred stats and pull them one unit per request, + as a client with ``stats_update`` would. Returns the first + ``initial_state``, the ids of the units that ran and every reply.""" + await self._load(sid, build_dir, stats_delivery="deferred", **body) + ws, first = await self._connect(sid, caps="stats_update") + gen = first["df_meta"]["stats"]["gen"] + replies = [] + with _spy_units() as ran: + for _ in range(12): + ws.write_message(_stats_request(gen, incremental=True)) + replies.append(await _read_json(ws)) + if replies[-1]["type"] != "stats_update" or replies[-1]["final"]: + break + return first, ran, replies + + @tornado.testing.gen_test + async def test_the_field_reaches_the_dataflow_and_is_stored_beside_the_kwargs(self): + await self._load("cc-field", stat_chunk_cells=36) + session = self._session("cc-field") + self.assertEqual(session.xorq_dataflow.stat_chunk_cells, 36) + self.assertEqual(session.stat_chunk_cells, 36) + self.assertNotIn("stat_chunk_cells", session.dataflow_kwargs) + + @tornado.testing.gen_test + async def test_the_default_is_off_and_null_is_the_same_as_absent(self): + with _count_stat_queries() as queries: + await self._load("cc-default") + self.assertEqual(len(_batch_queries(queries)), 1) + await self._load("cc-null", stat_chunk_cells=None) + for sid in ("cc-default", "cc-null"): + self.assertIsNone(self._session(sid).stat_chunk_cells) + self.assertIsNone(self._session(sid).xorq_dataflow.stat_chunk_cells) + _, default = await self._connect("cc-default") + _, null = await self._connect("cc-null") + self.assertEqual(_comparable(default), _comparable(null)) + + @tornado.testing.gen_test + async def test_a_parquet_scan_runs_several_batch_units_and_assembles_the_unsplit_stats(self): + _, whole_ran, whole = await self._pull("cc-whole") + first, ran, replies = await self._pull("cc-split", stat_chunk_cells=36) + self.assertEqual(_batch_ids(whole_ran), ["batch"]) + self.assertEqual(_batch_ids(ran), ["batch:0", "batch:1"]) + self.assertEqual(len(ran), len(whole_ran) + 1) + self.assertEqual((first["df_meta"]["stats"]["status"], first["df_meta"]["stats"]["tier"]), + ("pending", "schema")) + self.assertEqual([r["type"] for r in replies], ["stats_update"] * len(ran)) + self.assertTrue(replies[-1]["final"]) + self.assertEqual(_as_json(_rows_by_stat(replies[-1]["payload"])), + _as_json(_rows_by_stat(whole[-1]["payload"]))) + self.assertEqual(_as_json(self._session("cc-split").xorq_dataflow.merged_sd), + _as_json(self._session("cc-whole").xorq_dataflow.merged_sd)) + + @tornado.testing.gen_test + async def test_inline_stats_are_cut_too_and_the_frame_is_unchanged(self): + await self._load("cc-inline-whole") + _, whole = await self._connect("cc-inline-whole") + with _count_stat_queries() as queries: + await self._load("cc-inline", stat_chunk_cells=36) + self.assertEqual(len(_batch_queries(queries)), 2) + _, frame = await self._connect("cc-inline") + self.assertEqual(_comparable(frame), _comparable(whole)) + + @tornado.testing.gen_test + async def test_a_join_keeps_the_single_batch_and_nothing_reports_a_problem(self): + _, _, whole = await self._pull("cc-join-whole", self.join_build) + first, ran, replies = await self._pull("cc-join", self.join_build, stat_chunk_cells=12) + self.assertEqual(self._session("cc-join").stat_chunk_cells, 12) + self.assertEqual(_batch_ids(ran), ["batch"]) + self.assertEqual(set(first["df_meta"]["stats"]), {"status", "tier", "gen"}) + self.assertEqual(first["df_meta"]["stats"]["status"], "pending") + for reply in replies: + self.assertEqual(reply["type"], "stats_update") + self.assertNotIn("reason", reply) + self.assertNotIn("status", reply) + self.assertTrue(replies[-1]["final"]) + self.assertEqual(_as_json(_rows_by_stat(replies[-1]["payload"])), + _as_json(_rows_by_stat(whole[-1]["payload"]))) + _, done = await self._connect("cc-join", caps="stats_update") + self.assertEqual(done["df_meta"]["stats"]["status"], "complete") + self.assertNotIn("reason", done["df_meta"]["stats"]) + + @tornado.testing.gen_test + async def test_invalid_values_are_a_400(self): + for value in (0, -36, 1.5, 36.0, True, False, "36", [36], {"cells": 36}): + resp = await self._post_load("cc-bad", stat_chunk_cells=value) + self.assertEqual(resp.code, 400, value) + body = json.loads(resp.body) + self.assertEqual(body["error_code"], "invalid_stat_chunk_cells", value) + self.assertIn("stat_chunk_cells", body["message"]) + self.assertIsNone(self._session("cc-bad")) + + @tornado.testing.gen_test + async def test_an_invalid_value_leaves_the_stored_one(self): + await self._load("cc-kept", stat_chunk_cells=36) + before = self._session("cc-kept").xorq_dataflow + resp = await self._post_load("cc-kept", stat_chunk_cells=0) + self.assertEqual(resp.code, 400) + session = self._session("cc-kept") + self.assertEqual(session.stat_chunk_cells, 36) + self.assertIs(session.xorq_dataflow, before) + + @tornado.testing.gen_test + async def test_warm_repost_with_an_unchanged_value_short_circuits(self): + body = {"stat_chunk_cells": 36} + await self._load("cc-warm", **body) + with patch.object(xorq_loading, "load_expr_build_dir", + side_effect=AssertionError("an unchanged value must take the warm exit")): + same = await self._post_load("cc-warm", **body) + omitted = await self._post_load("cc-warm") + self.assertEqual(same.code, 200) + self.assertEqual(omitted.code, 200, "omitting the field keeps the session's") + session = self._session("cc-warm") + self.assertEqual(session.stat_chunk_cells, 36) + self.assertEqual(session.xorq_dataflow.stat_chunk_cells, 36) + + @tornado.testing.gen_test + async def test_the_off_value_sent_on_every_post_does_not_defeat_the_warm_exit(self): + """has_config tests truthiness; the field is not in it, so a host that + always sends null (or a count) does not rebuild on every POST (#944).""" + await self._load("cc-warm-null", stat_chunk_cells=None) + with patch.object(xorq_loading, "load_expr_build_dir", + side_effect=AssertionError("the warm exit must not see the field as config")): + resp = await self._post_load("cc-warm-null", stat_chunk_cells=None) + self.assertEqual(resp.code, 200) + + @tornado.testing.gen_test + async def test_warm_repost_with_a_changed_value_rebuilds(self): + 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 self._load("cc-changed") + self.assertEqual(len(calls), 1) + await self._load("cc-changed", stat_chunk_cells=36) + self.assertEqual(len(calls), 2, "turning the split on must rebuild") + self.assertEqual(self._session("cc-changed").xorq_dataflow.stat_chunk_cells, 36) + await self._load("cc-changed", stat_chunk_cells=24) + self.assertEqual(len(calls), 3, "a different count must rebuild") + await self._load("cc-changed", stat_chunk_cells=None) + self.assertEqual(len(calls), 4, "an explicit null turns the split off, which must rebuild") + session = self._session("cc-changed") + self.assertIsNone(session.stat_chunk_cells) + self.assertIsNone(session.xorq_dataflow.stat_chunk_cells) + + @tornado.testing.gen_test + async def test_reload_expr_replays_the_stored_value(self): + await self._load("cc-reload", project_root=self.project_root, stat_chunk_cells=36) + before = self._session("cc-reload").xorq_dataflow + with _count_stat_queries() as queries: + resp = await _post(self.get_http_port(), "/reload_expr/cc-reload", {}) + self.assertEqual(resp.code, 200, resp.body) + session = self._session("cc-reload") + self.assertIsNot(session.xorq_dataflow, before) + self.assertEqual(session.xorq_dataflow.stat_chunk_cells, 36) + self.assertEqual(session.stat_chunk_cells, 36) + self.assertEqual(len(_batch_queries(queries)), 2, "the rebuilt dataflow must split its batch") + + @tornado.testing.gen_test + async def test_reload_expr_without_a_stored_value_builds_the_single_batch(self): + await self._load("cc-reload-off", project_root=self.project_root) + with _count_stat_queries() as queries: + resp = await _post(self.get_http_port(), "/reload_expr/cc-reload-off", {}) + self.assertEqual(resp.code, 200, resp.body) + self.assertIsNone(self._session("cc-reload-off").xorq_dataflow.stat_chunk_cells) + self.assertEqual(len(_batch_queries(queries)), 1) + + +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, {}) + + +# The table _build_stats_wire_dir builds: five rows, three columns. +_WIRE_ESTIMATE = {"rows": 5, "cols": 3} +_ONDEMAND = "stats_update,stats_ondemand" +# Limits that put the table in the scalar or the schema tier by size. +_SCALAR_BY_SIZE = {"full_auto_rows": 3, "scalar_auto_cells": 1_000} +_SCHEMA_BY_SIZE = {"full_auto_rows": 3, "scalar_auto_cells": 10} + + +@contextmanager +def _stats_limits(**limits): + """Server stats thresholds for the block, as ``BUCKAROO_STATS_*`` + environment overrides, which ``resolve_stats_policy`` reads on every call.""" + env = {f"BUCKAROO_STATS_{name.upper()}": str(value) for name, value in limits.items()} + with patch.dict(os.environ, env): + yield + + +# (host tier, limits, df_meta.stats an ondemand client sees, minus gen). The +# five-by-three table is full by default; each set of limits moves it down. +_POLICY_CASES = { + "auto, within every threshold": ("auto", {}, {"status": "pending", "tier": "schema", "tier_target": "full", + "requestable": [], "estimate": _WIRE_ESTIMATE}), + "auto, scalar by size": ("auto", {"full_auto_rows": 3}, {"status": "not_computed", "tier": "schema", + "reason": "size", "tier_target": "scalar", "estimate": _WIRE_ESTIMATE}), + "auto, schema by size": ("auto", {"full_auto_rows": 3, "scalar_auto_cells": 10}, {"status": "not_computed", + "tier": "schema", "reason": "size", "tier_target": "schema", "auto_request": False, + "requestable": ["scalar", "full"], "estimate": _WIRE_ESTIMATE}), + "full, over the ceiling": ("full", {"ceiling_full_rows": 3}, {"status": "not_computed", "tier": "schema", + "reason": "ceiling", "tier_target": "scalar", "requestable": [], "estimate": _WIRE_ESTIMATE}), + "scalar, named by the host": ("scalar", {}, {"status": "not_computed", "tier": "schema", "reason": "host", + "tier_target": "scalar", "estimate": _WIRE_ESTIMATE}), + "schema, named by the host": ("schema", {}, {"status": "not_computed", "tier": "schema", "reason": "host", + "tier_target": "schema", "auto_request": False, "requestable": ["scalar", "full"], + "estimate": _WIRE_ESTIMATE}), + "scalar, over the scalar ceiling": ("scalar", {"ceiling_scalar_cells": 10}, {"status": "not_computed", + "tier": "schema", "reason": "ceiling", "tier_target": "schema", "auto_request": False, "requestable": [], + "estimate": _WIRE_ESTIMATE}), +} + + +class TestStatsPolicyWire(tornado.testing.AsyncHTTPTestCase): + """The stats policy on a ``/load_expr`` session (rows-first p33): + ``stats_tier`` ``auto | full | scalar | schema`` stored with the pair, the + policy resolved after the schema-tier dataflow and the count exist, reported + in ``df_meta.stats`` and applied at WebSocket open for a client that + advertises ``stats_ondemand`` alone, with the version-skew cases.""" + + 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) + 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, limits=None, **body): + with _stats_limits(**(limits or {})): + 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 _reload(self, sid, limits=None, **body): + with _stats_limits(**(limits or {})): + resp = await _post(self.get_http_port(), f"/reload_expr/{sid}", body) + self.assertEqual(resp.code, 200, resp.body) + + async def _connect(self, sid, caps=None): + 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 + + def _schema_rows(self, frame): + return list(_rows_by_stat(frame["df_data_dict"]["all_stats"])) + + async def _inline_frame(self, sid): + """The complete message an inline session of the same build sends.""" + await self._load(sid) + return (await self._connect(sid))[1] + + def _assert_complete(self, frame, inline_frame): + stats = self._stats(frame) + self.assertEqual((stats["status"], stats["tier"]), ("complete", "full")) + self.assertIn("histogram_bins", _rows_by_stat(frame["df_data_dict"]["all_stats"])) + self.assertEqual(_comparable(frame), _comparable(inline_frame)) + + @tornado.testing.gen_test + async def test_every_stats_tier_value_is_accepted_and_stored(self): + for tier in ("auto", "full", "scalar", "schema"): + sid = f"pw-accept-{tier}" + await self._load(sid, stats_tier=tier, stats_delivery="deferred") + session = self._session(sid) + self.assertEqual((session.stats_tier, session.stats_delivery), (tier, "deferred")) + self.assertEqual(session.xorq_dataflow.stats_tier, "schema") + resp = await _post(self.get_http_port(), f"/reload_expr/{sid}", {"stats_tier": "auto"}) + self.assertEqual(resp.code, 200, resp.body) + self.assertEqual(session.stats_tier, "auto") + resp = await _post(self.get_http_port(), "/load_expr", + {"session": "pw-bad", "build_dir": self.build_path, "stats_tier": "none"}) + self.assertEqual((resp.code, json.loads(resp.body)["error_code"]), (400, "invalid_stats_tier")) + + @tornado.testing.gen_test + async def test_a_tier_named_below_full_builds_a_schema_dataflow_with_inline_delivery(self): + """No scalar-tier units exist yet, so a scalar target is built and held at + the schema tier.""" + await self._load("pw-scalar-inline", stats_tier="scalar") + session = self._session("pw-scalar-inline") + self.assertEqual((session.xorq_dataflow.stats_tier, session.stats_status, session.stats_reason), + ("schema", "not_computed", "host")) + self.assertEqual(session.stats_policy["tier_target"], "scalar") + + @tornado.testing.gen_test + async def test_the_default_tier_stays_full(self): + await self._load("pw-default", stats_delivery="deferred") + session = self._session("pw-default") + self.assertEqual(session.stats_tier, "full") + self.assertEqual(session.stats_policy["tier_target"], "full") + + @tornado.testing.gen_test + async def test_an_inline_session_has_no_schema_dataflow_to_resolve_against(self): + """The constructor runs the stats of an inline session, so auto is full + there and the session sends the message it always has.""" + with _count_stat_queries() as queries: + await self._load("pw-auto-inline", limits={"full_auto_rows": 1, "ceiling_full_rows": 1}, + stats_tier="auto") + session = self._session("pw-auto-inline") + self.assertEqual((session.stats_tier, session.stats_delivery, session.stats_policy), ("auto", "inline", None)) + self.assertEqual(session.xorq_dataflow.stats_tier, "full") + self.assertTrue(queries, "an inline session runs its stats in the constructor") + _, frame = await self._connect("pw-auto-inline", caps=_ONDEMAND) + self.assertNotIn("stats", frame["df_meta"]) + + @tornado.testing.gen_test + async def test_the_policy_resolves_once_the_schema_dataflow_and_the_count_exist(self): + events, calls = [], [] + original_metadata = xorq_loading.get_xorq_metadata + original_resolve = stats_wire.resolve_stats_policy + + def metadata(*args, **kwargs): + events.append("dataflow and count") + return original_metadata(*args, **kwargs) + + def resolve(*args, **kwargs): + events.append("policy") + calls.append((args, kwargs)) + return original_resolve(*args, **kwargs) + + with ( + patch.object(xorq_loading, "get_xorq_metadata", metadata), + patch.object(stats_wire, "resolve_stats_policy", resolve), + _count_stat_queries() as queries, + ): + await self._load("pw-order", stats_tier="auto", stats_delivery="deferred") + self.assertEqual(events, ["dataflow and count", "policy"]) + (args, kwargs), = calls + self.assertEqual((args[0], args[2], args[3], kwargs["host_tier"]), ("xorq", 5, 3, "auto")) + self.assertEqual(queries, [], "resolving the policy runs no data stat query") + + @tornado.testing.gen_test + async def test_the_resolved_policy_is_stored_on_the_session(self): + limits = {"full_auto_rows": 3, "scalar_auto_cells": 10} + await self._load("pw-store", limits=limits, stats_tier="auto", stats_delivery="deferred") + session = self._session("pw-store") + self.assertEqual(session.stats_policy, {"tier_target": "schema", "auto_request": False, + "requestable": ["scalar", "full"], "reason": "size", "estimate": _WIRE_ESTIMATE}) + self.assertEqual((session.stats_status, session.stats_reason), ("not_computed", "size")) + + @tornado.testing.gen_test + async def test_df_meta_stats_carries_the_policy_fields_for_an_ondemand_client(self): + for index, (name, (tier, limits, expected)) in enumerate(_POLICY_CASES.items()): + sid = f"pw-fields-{index}" + await self._load(sid, limits=limits, stats_tier=tier, stats_delivery="deferred") + _, frame = await self._connect(sid, caps=_ONDEMAND) + stats = self._stats(frame) + self.assertEqual(stats, {**expected, "gen": stats["gen"]}, name) + self.assertEqual(self._schema_rows(frame), ["dtype"], f"{name}: the first message is the schema tier") + + @tornado.testing.gen_test + async def test_a_warm_repost_with_the_same_auto_tier_short_circuits(self): + body = {"session": "pw-warm", "build_dir": self.build_path, "stats_tier": "auto", "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": "pw-warm", "build_dir": self.build_path}) + self.assertEqual((same.code, omitted.code), (200, 200)) + session = self._session("pw-warm") + self.assertEqual((session.stats_tier, session.stats_delivery), ("auto", "deferred")) + + @tornado.testing.gen_test + async def test_a_changed_tier_rebuilds_and_resolves_again(self): + sid = "pw-change" + await self._load(sid, stats_tier="auto", stats_delivery="deferred") + self.assertEqual(self._session(sid).stats_policy["tier_target"], "full") + calls = [] + original = xorq_loading.load_expr_build_dir + + 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 self._load(sid, stats_tier="scalar", stats_delivery="deferred") + self.assertEqual(len(calls), 1, "a changed tier must rebuild") + self.assertEqual(self._session(sid).stats_policy["tier_target"], "scalar") + await self._load(sid, stats_tier="scalar", stats_delivery="deferred") + self.assertEqual(len(calls), 1, "the same tier takes the warm exit") + + @tornado.testing.gen_test + async def test_reload_expr_resolves_the_policy_again(self): + sid = "pw-reload" + await self._load(sid, stats_tier="auto", stats_delivery="deferred") + ws, first = await self._connect(sid, caps=_ONDEMAND) + gen = self._stats(first)["gen"] + self.assertEqual(self._stats(first)["tier_target"], "full") + + await self._reload(sid, limits={"full_auto_rows": 3}) + pushed = self._stats(await _read_json(ws)) + self.assertEqual((pushed["status"], pushed["reason"], pushed["tier_target"]), + ("not_computed", "size", "scalar")) + self.assertGreater(pushed["gen"], gen) + self.assertEqual(self._session(sid).stats_policy["tier_target"], "scalar") + + await self._reload(sid, stats_tier="schema") + pushed = self._stats(await _read_json(ws)) + self.assertEqual((pushed["reason"], pushed["tier_target"], pushed["auto_request"]), ("host", "schema", False)) + self.assertEqual(self._session(sid).stats_tier, "schema") + + await self._reload(sid, stats_tier="auto") + pushed = self._stats(await _read_json(ws)) + self.assertEqual((pushed["status"], pushed["tier_target"]), ("pending", "full")) + + @tornado.testing.gen_test + async def test_a_reload_that_omits_the_tier_keeps_the_session_s_and_resolves_it_against_the_stored_count(self): + """The expression is reused, so its count is the one the load took + (``session.metadata``), and the reload runs no second count.""" + sid = "pw-reload-keep" + await self._load(sid, stats_tier="auto", stats_delivery="deferred") + session = self._session(sid) + session.metadata = {**session.metadata, "rows": 20_000_000} + await self._reload(sid) + self.assertEqual(session.stats_tier, "auto") + self.assertEqual((session.stats_policy["tier_target"], session.stats_policy["estimate"]), + ("scalar", {"rows": 20_000_000, "cols": 3})) + + @tornado.testing.gen_test + async def test_the_policy_survives_a_dataflow_field_change(self): + await self._load("pw-keep", limits=_SCALAR_BY_SIZE, stats_tier="auto", stats_delivery="deferred") + ws, first = await self._connect("pw-keep", caps=_ONDEMAND) + gen = self._stats(first)["gen"] + ws.write_message(_state_change(quick_command_args={"search": ["a"]})) + stats = self._stats(await _read_json(ws)) + self.assertEqual((stats["status"], stats["reason"], stats["tier_target"], stats["gen"]), + ("not_computed", "size", "scalar", gen + 1)) + + # -- version skew ------------------------------------------------------- + + async def _policy_session(self, sid, **limits): + """A deferred ``auto`` session that resolved to the schema tier by size.""" + await self._load(sid, limits=limits or _SCHEMA_BY_SIZE, stats_tier="auto", stats_delivery="deferred") + + @tornado.testing.gen_test + async def test_no_caps_gets_a_complete_state_at_connect(self): + inline = await self._inline_frame("pw-skew-inline") + await self._policy_session("pw-skew-none") + _, legacy = await self._connect("pw-skew-none") + self._assert_complete(legacy, inline) + self.assertEqual(self._session("pw-skew-none").stats_status, "complete") + + @tornado.testing.gen_test + async def test_stats_ondemand_without_stats_update_is_a_client_with_no_caps(self): + inline = await self._inline_frame("pw-skew-inline-2") + await self._policy_session("pw-skew-odonly") + _, frame = await self._connect("pw-skew-odonly", caps="stats_ondemand") + self._assert_complete(frame, inline) + + @tornado.testing.gen_test + async def test_stats_update_only_is_served_as_a_session_headed_for_full(self): + await self._policy_session("pw-skew-update") + ws, first = await self._connect("pw-skew-update", caps="stats_update") + stats = self._stats(first) + self.assertEqual(stats, {"status": "pending", "tier": "schema", "gen": stats["gen"]}) + self.assertEqual(self._schema_rows(first), ["dtype"]) + + ws.write_message(_stats_request(stats["gen"])) + update = await _read_json(ws) + self.assertEqual((update["type"], update["final"], update["tier"]), ("stats_update", True, "full")) + self.assertIn("histogram_bins", _rows_by_stat(update["payload"])) + self.assertEqual(self._session("pw-skew-update").stats_status, "complete") + # The pending frame it was sent is what the final reply is compared with: + # the float column's minWidth changes with the stats, so the config follows. + self.assertIn("df_display_args", update) + + @tornado.testing.gen_test + async def test_stats_update_only_pulls_units_for_a_policy_session(self): + patcher = patch.object(stats_wire, "STATS_BUDGET_S", 0) + patcher.start() + self.addCleanup(patcher.stop) + await self._policy_session("pw-skew-units") + ws, first = await self._connect("pw-skew-units", caps="stats_update") + gen = self._stats(first)["gen"] + replies = [] + for _ in range(12): + ws.write_message(_stats_request(gen, incremental=True)) + replies.append(await _read_json(ws)) + if replies[-1]["final"]: + break + 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["tier"] for r in replies}, {"full"}, "the units are full-tier whatever the host asked") + self.assertEqual(self._session("pw-skew-units").stats_status, "complete") + + @tornado.testing.gen_test + async def test_both_bits_get_the_schema_tier_not_computed_and_run_no_stat_query(self): + await self._policy_session("pw-skew-both") + with _count_stat_queries() as queries: + ws, first = await self._connect("pw-skew-both", caps=_ONDEMAND) + stats = self._stats(first) + self.assertEqual((stats["status"], stats["tier"], stats["reason"], stats["tier_target"]), + ("not_computed", "schema", "size", "schema")) + self.assertEqual((stats["auto_request"], stats["requestable"]), (False, ["scalar", "full"])) + self.assertEqual(self._schema_rows(first), ["dtype"]) + self.assertEqual(queries, []) + # The server enforces it: nothing is requestable until the request path serves a tier. + ws.write_message(_stats_request(stats["gen"])) + aborted = await _read_json(ws) + self.assertEqual((aborted["type"], aborted["reason"]), ("stats_aborted", "not_requestable")) + self.assertEqual(self._session("pw-skew-both").stats_status, "not_computed") + + @tornado.testing.gen_test + async def test_the_three_client_kinds_see_one_session_each_their_own_way(self): + inline = await self._inline_frame("pw-skew-inline-3") + await self._policy_session("pw-skew-mixed") + _, ondemand = await self._connect("pw-skew-mixed", caps=_ONDEMAND) + _, update = await self._connect("pw-skew-mixed", caps="stats_update") + self.assertEqual(self._stats(ondemand)["status"], "not_computed") + self.assertEqual(self._stats(update)["status"], "pending") + _, legacy = await self._connect("pw-skew-mixed") + self._assert_complete(legacy, inline) + + @tornado.testing.gen_test + async def test_an_ondemand_client_is_sent_its_frame_before_a_legacy_client_completes_the_session(self): + await self._policy_session("pw-skew-order", **_SCALAR_BY_SIZE) + a, a_open = await self._connect("pw-skew-order", caps=_ONDEMAND) + gen = self._stats(a_open)["gen"] + b, b_open = await self._connect("pw-skew-order") + self.assertEqual(self._stats(b_open)["status"], "complete") + + a.write_message(_state_change(quick_command_args={"search": ["a"]})) + a_frame, b_frame = await _read_json(a), await _read_json(b) + self.assertEqual((self._stats(a_frame)["status"], self._stats(a_frame)["gen"]), ("not_computed", gen + 1)) + self.assertEqual((self._stats(b_frame)["status"], self._stats(b_frame)["gen"]), ("complete", gen + 1)) + + @tornado.testing.gen_test + async def test_a_tier_the_host_named_reaches_every_client_as_the_host_chose_it(self): + await self._load("pw-skew-host", stats_tier="scalar", stats_delivery="deferred") + _, legacy = await self._connect("pw-skew-host") + self.assertEqual(self._schema_rows(legacy), ["dtype"]) + self.assertEqual(self._stats(legacy)["status"], "not_computed") + ws, update = await self._connect("pw-skew-host", caps="stats_update") + self.assertEqual(self._stats(update), {"status": "not_computed", "tier": "schema", + "gen": self._stats(update)["gen"], "reason": "host"}) + ws.write_message(_stats_request(self._stats(update)["gen"])) + self.assertEqual((await _read_json(ws))["reason"], "not_requestable") + _, ondemand = await self._connect("pw-skew-host", caps=_ONDEMAND) + self.assertEqual(self._stats(ondemand)["tier_target"], "scalar") + + @tornado.testing.gen_test + async def test_a_client_of_an_old_server_reads_a_frame_with_no_stats_as_complete(self): + """An old server sends no ``df_meta.stats`` and no policy field; a new + client falls back to the documented defaults and waits for nothing.""" + await self._load("pw-old") + _, frame = await self._connect("pw-old", caps=_ONDEMAND) + stats = session_mod.stats_with_defaults(frame["df_meta"]) + self.assertEqual((stats["status"], stats["tier"], stats["tier_target"]), ("complete", "full", "full")) + self.assertEqual((stats["auto_request"], stats["requestable"]), (True, ["full"])) + self.assertEqual(stats["demand_columns"], []) + + @tornado.testing.gen_test + async def test_an_explicit_full_changes_no_frame(self): + """With ``stats_tier`` full the message is the one sent without the + field, and the policy is recorded but not reported.""" + for delivery in ("inline", "deferred"): + await self._load(f"pw-same-{delivery}-a", stats_delivery=delivery) + await self._load(f"pw-same-{delivery}-b", stats_tier="full", stats_delivery=delivery) + # A client with no caps completes a deferred session, so it goes last. + for caps in ("stats_update", _ONDEMAND, None): + _, before = await self._connect(f"pw-same-{delivery}-a", caps=caps) + _, after = await self._connect(f"pw-same-{delivery}-b", caps=caps) + self.assertEqual(_as_json(before["df_meta"]), _as_json(after["df_meta"]), (delivery, caps)) + self.assertEqual(_comparable(before), _comparable(after), (delivery, caps)) + self.assertEqual(list(before), list(after)) + if delivery == "deferred" and caps: + stats = self._stats(after) + self.assertEqual(stats, {"status": "pending", "tier": "schema", "gen": stats["gen"]}, caps) + self.assertEqual(self._session("pw-same-deferred-b").stats_policy["tier_target"], "full") + self.assertIsNone(self._session("pw-same-inline-b").stats_policy) + + +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) + + +# The stat rows the wire carries for the five-by-three table: the scalar tier is +# the full tier's rows without the three it leaves out. +_SCALAR_WIRE_ONLY = {"dtype", "min", "max", "null_count", "mean", "std", "non_null_count", "histogram_bins"} +_FULL_ONLY_WIRE = {"distinct_count", "median", "histogram"} + +# The three ways an entry reaches a scalar target, as (host tier, limits). +_SCALAR_TARGETS = {"by size": ("auto", _SCALAR_BY_SIZE), "named by the host": ("scalar", {}), + "full over the ceiling": ("full", {"ceiling_full_rows": 3})} + + +def _scalar_session(dataflow): + """A buckaroo-mode session over ``dataflow`` whose policy target is + ``scalar`` and has nothing computed, as ``/load_expr`` leaves one.""" + session = session_mod.SessionState(session_id="s", path="p") + session.mode, session.backend, session.xorq_dataflow = "buckaroo", "xorq", dataflow + session.stats_tier, session.stats_delivery = "auto", "deferred" + session.stats_policy = {"tier_target": "scalar", "auto_request": True, "requestable": ["full"], + "reason": "size", "estimate": {"rows": 12, "cols": 6}} + session_mod.begin_stats_generation(session) + return session + + +def _ondemand_client(): + return SimpleNamespace(caps=frozenset({"stats_update", "stats_ondemand"}), search_string="", + stats_cursor=StatCursor(), display_args_hash=None) + + +class TestScalarTierWire(tornado.testing.AsyncHTTPTestCase): + """The scalar tier on a ``/load_expr`` session whose policy target is + ``scalar`` (rows-first p34). A client that advertised ``stats_ondemand`` + pulls it with ``stats_request`` and gets ``stats_update`` messages whose + tier is ``scalar``. The run behaves like a filtered one: fragments go to + the clients and nothing is assigned to the dataflow, the session snapshot + or ``summary_stats_cache``, so the scalar stats can never be served as the + complete ones.""" + + 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) + 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, limits=None, **body): + with _stats_limits(**(limits or {})): + 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 _scalar_target(self, sid): + """A deferred ``auto`` session that resolved to a scalar target by size.""" + await self._load(sid, limits=_SCALAR_BY_SIZE, stats_tier="auto", stats_delivery="deferred") + + async def _connect(self, sid, caps=None): + 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): + await self._load(sid) + return (await self._connect(sid))[1] + + async def _pull(self, ws, gen, **fields): + """The reply to an incremental ``stats_request``.""" + ws.write_message(_stats_request(gen, incremental=True, **fields)) + return await _read_json(ws) + + async def _pull_update(self, ws, gen, **fields): + """The reply to an incremental ``stats_request``, which must be a ``stats_update``.""" + reply = await self._pull(ws, gen, **fields) + self.assertEqual(reply["type"], "stats_update", reply) + return reply + + @tornado.testing.gen_test + async def test_an_ondemand_client_pulls_the_scalar_tier_of_a_scalar_target(self): + for index, (name, (tier, limits)) in enumerate(_SCALAR_TARGETS.items()): + for incremental in (True, False): + sid = f"st-pull-{index}-{incremental}" + await self._load(sid, limits=limits, stats_tier=tier, stats_delivery="deferred") + ws, first = await self._connect(sid, caps=_ONDEMAND) + stats = self._stats(first) + self.assertEqual((stats["status"], stats["tier_target"]), ("not_computed", "scalar"), name) + + with _count_stat_queries() as queries: + ws.write_message(_stats_request(stats["gen"], **({"incremental": True} if incremental else {}))) + update = await _read_json(ws) + + label = f"{name}, incremental={incremental}" + self.assertEqual(update["type"], "stats_update", f"{label}: {update}") + self.assertEqual((update["tier"], update["final"], update["remaining"]), ("scalar", True, 0), label) + self.assertEqual((update["stats_gen"], update["scope"]), (stats["gen"], "raw"), label) + self.assertIsInstance(update["elapsed_ms"], (int, float)) + self.assertEqual(set(_rows_by_stat(update["payload"])), _SCALAR_WIRE_ONLY, label) + self.assertEqual([type(q.op()).__name__ for q in queries], ["Aggregate"], label) + self.assertFalse(list(queries[0].op().find((ops.ApproxMedian, ops.CountDistinct))), label) + + @tornado.testing.gen_test + async def test_the_scalar_fragments_equal_the_full_stats_for_the_keys_they_cover(self): + full = _rows_by_stat((await self._inline_frame("st-eq-inline"))["df_data_dict"]["all_stats"]) + await self._scalar_target("st-eq") + ws, first = await self._connect("st-eq", caps=_ONDEMAND) + scalar = _rows_by_stat((await self._pull_update(ws, self._stats(first)["gen"]))["payload"]) + + self.assertEqual(set(scalar), set(full) - _FULL_ONLY_WIRE) + for stat, row in scalar.items(): + for column, value in row.items(): + if column in ("index", "level_0") or (stat, column) == ("histogram_bins", "b"): + continue + self.assertEqual(_as_json(value), _as_json(full[stat][column]), (stat, column)) + # qty has three distinct values: unknown at this tier, so it gets bins that the full tier leaves out. + self.assertEqual((len(scalar["histogram_bins"]["b"]), full["histogram_bins"]["b"]), (11, [])) + + @tornado.testing.gen_test + async def test_a_scalar_run_assigns_nothing(self): + sid = "st-nothing" + await self._scalar_target(sid) + session = self._session(sid) + dataflow = session.xorq_dataflow + ws, first = await self._connect(sid, caps=_ONDEMAND) + gen = self._stats(first)["gen"] + held = (dataflow.summary_sd, session.df_data_dict, session.df_display_args, session.df_meta) + cached = set(dataflow.summary_stats_cache) + + await self._pull_update(ws, gen) + + self.assertTrue(all(a is b for a, b in zip(held, + (dataflow.summary_sd, session.df_data_dict, session.df_display_args, session.df_meta)))) + self.assertEqual(set(dataflow.summary_stats_cache), cached, "nothing is written to summary_stats_cache") + self.assertEqual((session.stats_status, session.stats_reason, dataflow.stats_tier), + ("not_computed", "size", "schema")) + self.assertEqual(list(session.stat_runs), [(gen, "raw", "scalar", None)], "the run is kept for later clients") + _, later = await self._connect(sid, caps=_ONDEMAND) + self.assertEqual((self._stats(later)["status"], list(_rows_by_stat(later["df_data_dict"]["all_stats"]))), + ("not_computed", ["dtype"]), "a client that connects later is told the same as the first one") + + @tornado.testing.gen_test + async def test_a_client_that_asks_later_reads_the_fragments_the_run_holds(self): + sid = "st-later" + await self._scalar_target(sid) + a, first = await self._connect(sid, caps=_ONDEMAND) + gen = self._stats(first)["gen"] + rows = _rows_by_stat((await self._pull_update(a, gen))["payload"]) + b, _ = await self._connect(sid, caps=_ONDEMAND) + + with _count_stat_queries() as queries: + caught_up = await self._pull_update(b, gen) + + self.assertEqual(queries, [], "the run's units ran once") + self.assertEqual((caught_up["tier"], caught_up["final"]), ("scalar", True)) + self.assertEqual(_as_json(_rows_by_stat(caught_up["payload"])), _as_json(rows)) + + @tornado.testing.gen_test + async def test_a_scalar_run_is_never_served_as_the_complete_stats(self): + inline = await self._inline_frame("st-never-inline") + await self._scalar_target("st-never") + a, first = await self._connect("st-never", caps=_ONDEMAND) + gen = self._stats(first)["gen"] + self.assertEqual((await self._pull_update(a, gen))["tier"], "scalar") + + # The policy put the session below full for a client that cannot take that: it is owed the full stats. + _, legacy = await self._connect("st-never") + stats = self._stats(legacy) + self.assertEqual((stats["status"], stats["tier"]), ("complete", "full")) + self.assertEqual(_comparable(legacy), _comparable(inline)) + self.assertEqual(set(_rows_by_stat(legacy["df_data_dict"]["all_stats"])), + set(_rows_by_stat(inline["df_data_dict"]["all_stats"]))) + self.assertTrue(_FULL_ONLY_WIRE <= set(_rows_by_stat(legacy["df_data_dict"]["all_stats"]))) + # Once the full stats exist a request for the session is answered with them. + update = await self._pull_update(a, gen) + self.assertEqual((update["tier"], update["final"]), ("full", True)) + self.assertTrue(_FULL_ONLY_WIRE <= set(_rows_by_stat(update["payload"]))) + + @tornado.testing.gen_test + async def test_a_scalar_request_emits_the_request_and_unit_spans_with_the_scalar_tier(self): + captured: list = [] + with patch.object(telemetry, "make_http_sink", lambda url, **kw: captured.append): + await self._load("st-span", limits=_SCALAR_BY_SIZE, stats_tier="auto", stats_delivery="deferred", + telemetry_url="http://companion.invalid/internal/telemetry") + ws, first = await self._connect("st-span", caps=_ONDEMAND) + gen = self._stats(first)["gen"] + await self._pull_update(ws, gen) + + (request,) = [r for r in captured if r["name"] == "stats.request"] + (unit,) = [r for r in captured if r["name"] == "stats.unit"] + self.assertEqual((request["attrs"]["tier"], request["attrs"]["outcome"], request["attrs"]["final"]), + ("scalar", "update", True)) + self.assertEqual((request["attrs"]["stats_gen"], request["attrs"]["units"]), (gen, 1)) + self.assertEqual((unit["attrs"]["unit"], unit["attrs"]["phase"], unit["attrs"]["cost"]), + ("batch", "batch", "scan")) + self.assertNotIn("firstpull.stats_total", [r["name"] for r in captured], "nothing is completed") + + @tornado.testing.gen_test + async def test_a_dataflow_field_change_starts_a_new_scalar_run_over_the_new_state(self): + sid = "st-change" + await self._scalar_target(sid) + session = self._session(sid) + ws, first = await self._connect(sid, caps=_ONDEMAND) + gen = self._stats(first)["gen"] + await self._pull_update(ws, gen) + self.assertEqual(session.stat_runs[(gen, "raw", "scalar", None)].acc.sd()["qty"]["length"], 5) + + ws.write_message(_state_change(quick_command_args={"search": ["a"]})) + changed = await _read_json(ws) + self.assertEqual((self._stats(changed)["status"], self._stats(changed)["gen"]), ("not_computed", gen + 1)) + self.assertEqual(session.stat_runs, {}, "the run of the old generation is dropped") + stale = await self._pull(ws, gen) + self.assertEqual((stale["type"], stale["reason"]), ("stats_aborted", "stale")) + after = await self._pull_update(ws, gen + 1) + self.assertEqual((after["stats_gen"], after["tier"]), (gen + 1, "scalar")) + self.assertEqual(session.stat_runs[(gen + 1, "raw", "scalar", None)].acc.sd()["qty"]["length"], 2, + "the run is over the filtered state") + + @tornado.testing.gen_test + async def test_the_clients_that_cannot_take_a_scalar_target_are_served_as_before(self): + await self._load("st-host", stats_tier="scalar", stats_delivery="deferred") + ws, update = await self._connect("st-host", caps="stats_update") + ws.write_message(_stats_request(self._stats(update)["gen"], incremental=True)) + self.assertEqual((await _read_json(ws))["reason"], "not_requestable") + + await self._scalar_target("st-skew") + ws, pending = await self._connect("st-skew", caps="stats_update") + self.assertEqual(self._stats(pending)["status"], "pending") + reply = await self._pull(ws, self._stats(pending)["gen"]) + self.assertEqual((reply["type"], reply["tier"]), ("stats_update", "full")) + + @tornado.testing.gen_test + async def test_a_schema_target_still_refuses_the_request(self): + await self._load("st-schema", limits=_SCHEMA_BY_SIZE, stats_tier="auto", stats_delivery="deferred") + ws, first = await self._connect("st-schema", caps=_ONDEMAND) + aborted = await self._pull(ws, self._stats(first)["gen"]) + self.assertEqual((aborted["type"], aborted["reason"]), ("stats_aborted", "not_requestable")) + + @tornado.testing.gen_test + async def test_a_scalar_run_and_a_column_scoped_run_are_stored_apart_from_the_full_run(self): + await self._load("st-keys", stats_delivery="deferred") + session = self._session("st-keys") + runs = {"full": start_stat_run(session), "scalar": start_stat_run(session, tier="scalar"), + "scalar group": start_stat_run(session, tier="scalar", columns=("qty",)), + "full group": start_stat_run(session, tier="full", columns=("qty",))} + gen = session.stats_gen + self.assertEqual({name: run.key for name, run in runs.items()}, { + "full": (gen, "raw"), "scalar": (gen, "raw", "scalar", None), + "scalar group": (gen, "raw", "scalar", ("qty",)), "full group": (gen, "raw", "full", ("qty",))}) + self.assertEqual(session.stat_runs, {run.key: run for run in runs.values()}) + self.assertIs(start_stat_run(session, tier="scalar"), runs["scalar"], "one run for a tier and group") + self.assertEqual({name: run.assigns for name, run in runs.items()}, + {"full": True, "scalar": False, "scalar group": False, "full group": False}) + self.assertEqual([u.id for u in runs["scalar"].units], ["batch"]) + self.assertEqual([u.id for u in runs["scalar group"].units], ["batch"]) + self.assertEqual([u.columns for u in runs["full group"].units], [("qty",), ("qty",)]) + + @tornado.testing.gen_test + async def test_runs_that_assign_nothing_leave_the_dataflow_the_snapshot_and_the_cache_alone(self): + await self._load("st-scoped", stats_delivery="deferred") + session = self._session("st-scoped") + dataflow = session.xorq_dataflow + held = (dataflow.summary_sd, session.df_data_dict) + cached = set(dataflow.summary_stats_cache) + key = dataflow._scope_cache_key(split_chain_by_scope(dataflow.operations)["filt"], tier="full") + + for tier, columns in (("scalar", None), ("scalar", ("qty",)), ("full", ("qty",))): + run = start_stat_run(session, tier=tier, columns=columns) + stats_wire.run_units(run, None) + self.assertEqual(run.status, "complete") + rows = _rows_by_stat(stats_wire.partial_payload(dataflow, run, run.fragments)) + columns_sent = {c for row in rows.values() for c in row} - {"index", "level_0"} + self.assertEqual(columns_sent, {"a", "b", "c"} if columns is None else {"b"}, (tier, columns)) + self.assertTrue(all(a is b for a, b in zip(held, (dataflow.summary_sd, session.df_data_dict)))) + self.assertEqual(set(dataflow.summary_stats_cache), cached) + self.assertEqual((session.stats_status, dataflow.stats_tier), ("pending", "schema")) + + # The run that is assigned is a whole full run, whatever else ran. + self.assertTrue(stats_wire.complete_stats(session)) + self.assertEqual(set(dataflow.summary_stats_cache) - cached, {key}) + self.assertTrue(_FULL_ONLY_WIRE <= set(_rows_by_stat(session.df_data_dict["all_stats"]))) + + @tornado.testing.gen_test + async def test_a_scalar_run_is_not_assigned_even_if_it_is_stored_under_the_full_key(self): + await self._load("st-guard", stats_delivery="deferred") + session = self._session("st-guard") + dataflow = session.xorq_dataflow + cached = set(dataflow.summary_stats_cache) + run = start_stat_run(session, tier="scalar") + stats_wire.run_units(run, None) + session.stat_runs[(session.stats_gen, "raw")] = run + + with self.assertLogs("buckaroo.server.stats_wire", level="ERROR"): + self.assertFalse(stats_wire.complete_stats(session)) + + self.assertEqual((session.stats_status, session.stats_reason), ("error", "stats_failed")) + self.assertEqual(set(dataflow.summary_stats_cache), cached) + + +class TestScalarTierRequests: + """An incremental scalar request on a session whose batch is cut into + chunks (rows-first p34): one chunk per request, each reply reporting the + ``scalar`` tier, and the last one ``final``.""" + + @staticmethod + def _session(tmp_path, **kwargs): + expr = _wide_parquet_expr(tmp_path) + dataflow = xorq_loading.XorqServerDataflow(expr, skip_main_serial=True, stats_tier="schema", **kwargs) + return _scalar_session(dataflow) + + @staticmethod + def _request(session, client, **fields): + return stats_wire.handle_stats_request( + session, {"type": "stats_request", "stats_gen": session.stats_gen, "scope": "raw", **fields}, client) + + def test_each_request_runs_one_chunk_and_reports_the_scalar_tier(self, tmp_path, monkeypatch): + monkeypatch.setattr(stats_wire, "STATS_BUDGET_S", 0) + # 24 cells at 12 rows is two columns per chunk: three batch units. + session, client = self._session(tmp_path, stat_chunk_cells=24), _ondemand_client() + replies = [self._request(session, client, incremental=True) for _ in range(3)] + assert [r["type"] for r in replies] == ["stats_update"] * 3, replies + assert [(r["tier"], r["final"], r["remaining"]) for r in replies] == [ + ("scalar", False, 2), ("scalar", False, 1), ("scalar", True, 0)] + assert [{c for row in _rows_by_stat(r["payload"]).values() for c in row} - {"index", "level_0"} + for r in replies] == [{"a", "b"}, {"c", "d"}, {"e", "f"}] + whole = self._request(self._session(tmp_path), _ondemand_client()) + + def held(payloads): + """What a client holds, less the empty cells: a chunk with no column that has a stat sends no row for it.""" + return {stat: {col: value for col, value in cells.items() if value is not None} + for stat, cells in _merge_stat_payloads(payloads).items()} + + assert _as_json(held(r["payload"] for r in replies)) == _as_json(held([whole["payload"]])) + + def test_a_request_after_the_last_chunk_replays_nothing_and_runs_nothing(self, tmp_path, monkeypatch): + monkeypatch.setattr(stats_wire, "STATS_BUDGET_S", 0) + session, client = self._session(tmp_path, stat_chunk_cells=24), _ondemand_client() + for _ in range(3): + self._request(session, client, incremental=True) + with _count_stat_queries() as queries: + again = self._request(session, client, incremental=True) + assert queries == [] + assert again["type"] == "stats_update", again + assert (again["tier"], again["final"], again["remaining"]) == ("scalar", True, 0) + + def test_a_unit_that_fails_aborts_the_request_and_not_the_session(self, tmp_path, caplog): + session, client = self._session(tmp_path), _ondemand_client() + dataflow = session.xorq_dataflow + stats = dataflow.build_stats(dataflow.processed_df, run=False) + run = StatRun(session.stats_gen, "raw", _SlowStats(stats, fail_on="batch"), + state=dataclasses.replace(stats.state, tier="scalar")) + session.stat_runs[run.key] = run + for _ in range(2): + reply = self._request(session, client, incremental=True) + assert (reply["type"], reply["reason"]) == ("stats_aborted", "error") + assert run.status == "error", "a failed run is not retried" + assert (session.stats_status, session.stats_reason) == ("not_computed", "size"), "the full tier is still open" + assert any("stat unit failed" in record.getMessage() for record in caplog.records) + + +# --------------------------------------------------------------------------- +# Limits, override, cost guard and the demand scan (rows-first p36a) +# --------------------------------------------------------------------------- + + +@contextmanager +def _slow_stat_queries(delay): + """Every query ``XorqStatPipeline`` sends while the block runs takes + ``delay`` seconds longer, as a slow source would make it.""" + original = XorqStatPipeline._execute + + def slow(pipeline, query): + time.sleep(delay) + return original(pipeline, query) + + with patch.object(XorqStatPipeline, "_execute", slow): + yield + + +class _LimitsWire(tornado.testing.AsyncHTTPTestCase): + """What the p36a wire tests share: a ``/load_expr`` session over the + five-row table (deferred unless a test says otherwise) and clients that + advertise both capability bits. A request is judged against the thresholds + again, so a test that sets them holds them in the environment for the whole + block (``_stats_limits``), not only around the load.""" + + DISPLAY_FILES: dict = {} + + 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") + if self.DISPLAY_FILES: + display_dir = os.path.join(self.project_root, "display") + os.makedirs(display_dir) + for name, source in self.DISPLAY_FILES.items(): + with open(os.path.join(display_dir, name), "w") as f: + f.write(source) + 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) + + def _patch(self, target, name, value): + patcher = patch.object(target, name, value, create=True) + patcher.start() + self.addCleanup(patcher.stop) + + def _one_unit_per_request(self): + self._patch(stats_wire, "STATS_BUDGET_S", 0) + + def _cost_budget(self, seconds): + self._patch(stats_wire, "STATS_COST_BUDGET_S", seconds) + + 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, "stats_delivery": "deferred", **body}) + self.assertEqual(resp.code, 200, resp.body) + + async def _reload(self, sid, **body): + resp = await _post(self.get_http_port(), f"/reload_expr/{sid}", body) + self.assertEqual(resp.code, 200, resp.body) + + async def _connect(self, sid, caps=_ONDEMAND): + 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): + """The complete message an inline session of the same build sends.""" + await self._load(sid, stats_delivery="inline") + return (await self._connect(sid, caps=None))[1] + + async def _state(self, ws, **changes): + """A state change from ``ws``, and the frame it broadcasts.""" + ws.write_message(_state_change(**changes)) + return await _read_json(ws) + + async def _ask(self, ws, gen, **fields): + """The reply to an incremental ``stats_request``.""" + ws.write_message(_stats_request(gen, incremental=True, **fields)) + return await _read_json(ws) + + async def _ask_to_the_end(self, ws, gen, limit=12, **fields): + """Incremental requests, the same each time, until a reply is final.""" + replies = [] + for _ in range(limit): + replies.append(await self._ask(ws, gen, **fields)) + if replies[-1]["type"] != "stats_update" or replies[-1]["final"]: + break + return replies + + +# (host tier, limits, request fields): a request the ceiling refuses, whoever +# sends it. The table is 15 cells; a ceiling_full_rows of 3 refuses full for it +# and a ceiling_scalar_cells of 10 refuses scalar for the whole table. +_OVER_THE_CEILING = { + "full over a ceiling on full": ("auto", {**_SCHEMA_BY_SIZE, "ceiling_full_rows": 3}, + {"tier": "full", "force": True}), + "full, not forced": ("auto", {**_SCHEMA_BY_SIZE, "ceiling_full_rows": 3}, {"tier": "full"}), + "full, the tier the host asked for": ("full", {"ceiling_full_rows": 3}, {"tier": "full", "force": True}), + "scalar over the ceiling on scalar": ("schema", {"ceiling_scalar_cells": 10}, {"tier": "scalar", "force": True}), + "full over the ceiling on scalar": ("schema", {"ceiling_scalar_cells": 10}, {"tier": "full", "force": True}), + "every column of the table": ("schema", {"ceiling_scalar_cells": 10}, + {"tier": "scalar", "columns": ["a", "b", "c"]})} + + +class TestForcedRequestsWire(_LimitsWire): + """``stats_request`` with ``tier`` and ``force`` (rows-first p36a): the + ceiling, ``requestable`` and the session override, from a client that + advertised both bits.""" + + @tornado.testing.gen_test + async def test_a_request_over_the_ceiling_runs_no_unit(self): + for index, (name, (tier, limits, fields)) in enumerate(_OVER_THE_CEILING.items()): + with _stats_limits(**limits): + sid = f"fo-over-{index}" + await self._load(sid, stats_tier=tier) + session = self._session(sid) + ws, first = await self._connect(sid) + gen = self._stats(first)["gen"] + held = (session.stats_status, session.stats_reason) + with _count_stat_queries() as queries: + reply = await self._ask(ws, gen, **fields) + self.assertEqual(queries, [], name) + self.assertEqual({key: reply.get(key) for key in ("type", "final", "status", "reason", "remaining")}, + {"type": "stats_update", "final": True, "status": "not_computed", "reason": "ceiling", + "remaining": 0}, name) + self.assertEqual((reply["stats_gen"], reply["scope"]), (gen, "raw"), name) + self.assertNotIn("payload", reply, name) + self.assertEqual((session.stats_status, session.stats_reason, session.stat_runs, session.stats_override), + (*held, {}, None), name) + + @tornado.testing.gen_test + async def test_a_forced_scalar_request_under_the_ceiling_runs_and_raises_the_target(self): + with _stats_limits(**_SCHEMA_BY_SIZE): + await self._load("fo-scalar", stats_tier="auto") + session = self._session("fo-scalar") + ws, first = await self._connect("fo-scalar") + gen = self._stats(first)["gen"] + self.assertEqual((self._stats(first)["tier_target"], self._stats(first)["requestable"]), ( + "schema", ["scalar", "full"])) + with _count_stat_queries() as queries: + update = await self._ask(ws, gen, tier="scalar", force=True) + self.assertEqual((update["type"], update["tier"], update["final"], update["remaining"]), + ("stats_update", "scalar", True, 0), update) + self.assertEqual(set(_rows_by_stat(update["payload"])), _SCALAR_WIRE_ONLY) + self.assertEqual([type(q.op()).__name__ for q in queries], ["Aggregate"]) + self.assertEqual((update["status"], update["reason"]), ("not_computed", "size"), + "the final reply says the session still has no complete stats") + self.assertIsInstance(update["elapsed_ms"], (int, float)) + self.assertEqual(session.stats_override, "scalar") + self.assertEqual((session.stats_status, session.stats_reason, session.stats_policy["tier_target"]), + ("not_computed", "size", "schema")) + # A client that connects later is told the target the override set. + _, later = await self._connect("fo-scalar") + self.assertEqual(self._stats(later), {"status": "not_computed", "tier": "schema", "gen": gen, + "reason": "size", "tier_target": "scalar", "estimate": _WIRE_ESTIMATE}) + + @tornado.testing.gen_test + async def test_a_forced_full_request_runs_every_unit_and_completes_the_session(self): + self._one_unit_per_request() + inline = await self._inline_frame("fo-full-inline") + with _stats_limits(**_SCHEMA_BY_SIZE): + await self._load("fo-full", stats_tier="auto") + session = self._session("fo-full") + ws, first = await self._connect("fo-full") + gen = self._stats(first)["gen"] + self.assertEqual(self._stats(first)["status"], "not_computed") + + opening = await self._ask(ws, gen, tier="full", force=True) + # Run for real: the session is now headed for full, and says so. + _, mid = await self._connect("fo-full") + self.assertEqual({key: self._stats(mid).get(key) for key in ("status", "tier_target", "requestable")}, + {"status": "pending", "tier_target": "full", "requestable": []}) + rest = await self._ask_to_the_end(ws, gen, tier="full", force=True) + replies = [opening, *rest] + self.assertEqual([r["type"] for r in replies], ["stats_update"] * 4) + self.assertEqual([(r["tier"], r["final"], r["remaining"]) for r in replies], + [("full", False, 3), ("full", False, 2), ("full", False, 1), ("full", True, 0)]) + final = replies[-1] + self.assertNotIn("status", final, "a final reply that completes the session says nothing more") + self.assertTrue(_FULL_ONLY_WIRE <= set(_rows_by_stat(final["payload"]))) + self.assertIn("df_display_args", final, "the config the complete stats give is not the one the client holds") + self.assertEqual((session.stats_status, session.stats_override), ("complete", "full")) + _, legacy = await self._connect("fo-full", caps=None) + self.assertEqual(_comparable(legacy), _comparable(inline)) + + @tornado.testing.gen_test + async def test_a_request_above_the_target_without_force_is_not_requestable(self): + with _stats_limits(**_SCHEMA_BY_SIZE): + await self._load("fo-noforce", stats_tier="auto") + session = self._session("fo-noforce") + ws, first = await self._connect("fo-noforce") + gen = self._stats(first)["gen"] + for fields in ({"tier": "scalar"}, {"tier": "full"}, {"tier": "full", "force": False}): + with _count_stat_queries() as queries: + aborted = await self._ask(ws, gen, **fields) + self.assertEqual((aborted["type"], aborted["reason"]), ("stats_aborted", "not_requestable"), fields) + self.assertEqual(queries, [], fields) + self.assertEqual((session.stats_override, session.stat_runs), (None, {})) + + @tornado.testing.gen_test + async def test_a_request_below_the_target_is_not_requestable(self): + await self._load("fo-below", stats_tier="auto") + ws, first = await self._connect("fo-below") + self.assertEqual(self._stats(first)["tier_target"], "full") + aborted = await self._ask(ws, self._stats(first)["gen"], tier="scalar", force=True) + self.assertEqual((aborted["type"], aborted["reason"]), ("stats_aborted", "not_requestable")) + + @tornado.testing.gen_test + async def test_the_override_survives_a_dataflow_field_change_for_the_unfiltered_scope_only(self): + with _stats_limits(**_SCHEMA_BY_SIZE): + await self._load("fo-survive", stats_tier="auto") + ws, first = await self._connect("fo-survive") + gen = self._stats(first)["gen"] + # No force yet: a field change leaves the session where the policy put it. + plain = self._stats(await self._state(ws, post_processing="first_three")) + self.assertEqual((plain["status"], plain["reason"], plain["tier_target"], plain["gen"]), + ("not_computed", "size", "schema", gen + 1)) + gen += 1 + replies = await self._ask_to_the_end(ws, gen, tier="full", force=True) + self.assertEqual((replies[-1]["type"], replies[-1].get("final")), ("stats_update", True)) + + with _count_stat_queries() as queries: + unfiltered = self._stats(await self._state(ws, post_processing="")) + self.assertEqual((unfiltered["status"], unfiltered["tier_target"], unfiltered["gen"]), + ("pending", "full", gen + 1)) + self.assertNotIn("reason", unfiltered) + self.assertEqual(queries, [], "the new state is the schema tier again, and runs no stat query") + gen += 1 + + filtered = self._stats(await self._state(ws, quick_command_args={"search": ["a"]})) + self.assertEqual((filtered["status"], filtered["reason"], filtered["tier_target"], filtered["gen"]), + ("not_computed", "size", "schema", gen + 1), "a search is the filtered scope: the policy alone") + gen += 1 + + cleared = self._stats(await self._state(ws)) + self.assertEqual((cleared["status"], cleared["tier_target"], cleared["gen"]), ("pending", "full", gen + 1)) + + @tornado.testing.gen_test + async def test_a_force_in_a_filtered_state_is_served_for_that_state_and_waits_for_an_unfiltered_one(self): + with _stats_limits(**_SCHEMA_BY_SIZE): + await self._load("fo-filtered", stats_tier="auto") + session = self._session("fo-filtered") + ws, _first = await self._connect("fo-filtered") + searched = self._stats(await self._state(ws, quick_command_args={"search": ["a"]})) + self.assertEqual(searched["status"], "not_computed") + + replies = await self._ask_to_the_end(ws, searched["gen"], tier="full", force=True) + self.assertEqual((replies[-1]["type"], replies[-1].get("final")), ("stats_update", True), replies[-1]) + self.assertEqual(session.stats_status, "complete") + + cleared = self._stats(await self._state(ws)) + self.assertEqual((cleared["status"], cleared["tier_target"]), ("pending", "full")) + + @tornado.testing.gen_test + async def test_a_session_nobody_forced_stays_not_computed_through_a_field_change(self): + with _stats_limits(**_SCHEMA_BY_SIZE): + await self._load("fo-plain", stats_tier="auto") + ws, _first = await self._connect("fo-plain") + for changes in ({"post_processing": "first_three"}, {"post_processing": ""}, + {"quick_command_args": {"search": ["a"]}}, {}): + stats = self._stats(await self._state(ws, **changes)) + self.assertEqual((stats["status"], stats["tier_target"]), ("not_computed", "schema"), changes) + + @tornado.testing.gen_test + async def test_a_reload_keeps_the_override_unless_it_names_a_tier(self): + with _stats_limits(**_SCHEMA_BY_SIZE): + await self._load("fo-reload", stats_tier="auto") + session = self._session("fo-reload") + ws, first = await self._connect("fo-reload") + await self._ask_to_the_end(ws, self._stats(first)["gen"], tier="full", force=True) + self.assertEqual(session.stats_override, "full") + + await self._reload("fo-reload") + kept = self._stats(await _read_json(ws)) + self.assertEqual((kept["status"], kept["tier_target"]), ("pending", "full")) + self.assertEqual(session.stats_override, "full") + + await self._reload("fo-reload", stats_tier="auto") + reset = self._stats(await _read_json(ws)) + self.assertEqual((reset["status"], reset["reason"], reset["tier_target"]), + ("not_computed", "size", "schema")) + self.assertIsNone(session.stats_override) + + @tornado.testing.gen_test + async def test_a_new_expression_forgets_the_override_and_a_rebuild_of_the_same_one_keeps_it(self): + with _stats_limits(**_SCHEMA_BY_SIZE): + await self._load("fo-new", stats_tier="auto") + session = self._session("fo-new") + ws, first = await self._connect("fo-new") + await self._ask_to_the_end(ws, self._stats(first)["gen"], tier="full", force=True) + self.assertEqual(session.stats_override, "full") + + resp = await _post(self.get_http_port(), "/load_expr", {"session": "fo-new", "build_dir": self.build_path, + "stats_delivery": "deferred", "force_reload": True}) + self.assertEqual(resp.code, 200, resp.body) + self.assertEqual(session.stats_override, "full", "the same expression, rebuilt, is the same dataset") + + other_root = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, other_root, ignore_errors=True) + resp = await _post(self.get_http_port(), "/load_expr", {"session": "fo-new", + "build_dir": _build_stats_wire_dir(other_root), "stats_delivery": "deferred"}) + self.assertEqual(resp.code, 200, resp.body) + self.assertIsNone(session.stats_override) + self.assertEqual((session.stats_status, session.stats_reason), ("not_computed", "size")) + + @tornado.testing.gen_test + async def test_load_and_load_compare_forget_the_override_and_the_pause(self): + """They swap the session to a dataset with no policy, so what was set under the old one must not wait + for the next /load_expr.""" + paths = [] + try: + for index in range(3): + fd, path = tempfile.mkstemp(suffix=".csv") + os.close(fd) + pd.DataFrame({"id": [1, 2], "v": [10 * (index + 1), 20]}).to_csv(path, index=False) + paths.append(path) + for sid, route, body in ( + ("fo-load", "/load", {"path": paths[0], "mode": "buckaroo"}), + ("fo-compare", "/load_compare", {"path1": paths[1], "path2": paths[2], "join_columns": ["id"]})): + await self._load(sid, stats_tier="auto") + session = self._session(sid) + session.stats_override, session.stats_override_gen, session.cost_paused = "full", session.stats_gen, True + resp = await _post(self.get_http_port(), route, {"session": sid, **body}) + self.assertEqual(resp.code, 200, resp.body) + self.assertEqual((session.stats_override, session.stats_override_gen, session.cost_paused), + (None, 0, False), route) + self.assertEqual((session.stats_policy, session.stats_status), (None, "complete"), route) + finally: + for path in paths: + os.unlink(path) + + @tornado.testing.gen_test + async def test_every_stats_update_carries_elapsed_ms_and_it_is_the_time_the_request_took(self): + self._one_unit_per_request() + self._cost_budget(3600) + with _stats_limits(**{**_SCHEMA_BY_SIZE, "ceiling_scalar_cells": 10}): + await self._load("fo-elapsed", stats_tier="auto") + ws, first = await self._connect("fo-elapsed") + gen = self._stats(first)["gen"] + with _slow_stat_queries(0.3): + scoped = await self._ask(ws, gen, tier="scalar", columns=["a"]) + refused = await self._ask(ws, gen, tier="full", force=True) + for name, reply in (("scoped", scoped), ("refused", refused)): + self.assertEqual(reply["type"], "stats_update", name) + self.assertIsInstance(reply["elapsed_ms"], (int, float), name) + # The margins are wide on both sides: a loaded machine stalls a refusal for tens of milliseconds. + self.assertGreaterEqual(scoped["elapsed_ms"], 250, "the request ran a query that took 300 ms") + self.assertLess(refused["elapsed_ms"], 250, "a refusal runs nothing") + + +class TestCostGuardWire(_LimitsWire): + """The cost guard (rows-first p36a): an automatic unit over the budget + pauses the session, held on the session, until a ``force`` request.""" + + async def _paused_session(self, sid): + """A deferred session whose first unit ran slow, one unit per request.""" + self._one_unit_per_request() + self._cost_budget(0.02) + await self._load(sid) + ws, first = await self._connect(sid) + gen = self._stats(first)["gen"] + self.assertEqual(self._stats(first)["status"], "pending") + with _slow_stat_queries(0.06): + update = await self._ask(ws, gen) + self.assertEqual((update["type"], update["final"], update["remaining"]), ("stats_update", False, 3), update) + return ws, gen + + @tornado.testing.gen_test + async def test_an_automatic_unit_over_the_budget_pauses_the_session(self): + ws, gen = await self._paused_session("cg-pause") + session = self._session("cg-pause") + self.assertTrue(session.cost_paused) + self.assertEqual((session.stats_status, session.stats_reason), ("not_computed", "cost")) + with _count_stat_queries() as queries: + refused = await self._ask(ws, gen) + self.assertEqual(queries, [], "the next request runs nothing") + self.assertEqual({key: refused.get(key) for key in ("type", "final", "status", "reason", "tier", "remaining")}, + {"type": "stats_update", "final": True, "status": "not_computed", "reason": "cost", "tier": "full", + "remaining": 0}) + self.assertNotIn("payload", refused) + self.assertIsInstance(refused["elapsed_ms"], (int, float)) + self.assertEqual(len(session.stat_runs[(gen, "raw")].ran), 1, "the unit that ran stays run") + + @tornado.testing.gen_test + async def test_a_unit_under_the_budget_does_not_pause_the_session(self): + self._one_unit_per_request() + self._cost_budget(3600) + await self._load("cg-fast") + ws, first = await self._connect("cg-fast") + with _slow_stat_queries(0.03): + replies = await self._ask_to_the_end(ws, self._stats(first)["gen"]) + self.assertEqual([r["final"] for r in replies], [False, False, False, True]) + session = self._session("cg-fast") + self.assertEqual((session.cost_paused, session.stats_status), (False, "complete")) + + @tornado.testing.gen_test + async def test_a_forced_unit_over_the_budget_does_not_pause_the_session(self): + self._one_unit_per_request() + self._cost_budget(0.02) + with _stats_limits(**_SCHEMA_BY_SIZE): + await self._load("cg-forced", stats_tier="auto") + ws, first = await self._connect("cg-forced") + gen = self._stats(first)["gen"] + with _slow_stat_queries(0.06): + opening = await self._ask(ws, gen, tier="full", force=True) + session = self._session("cg-forced") + self.assertEqual((opening["final"], opening["remaining"]), (False, 3)) + # Looked at here: a later forced request would clear a pause this one had set. + self.assertEqual((session.cost_paused, session.stats_status), (False, "pending")) + with _slow_stat_queries(0.06): + rest = await self._ask_to_the_end(ws, gen, tier="full", force=True) + self.assertEqual([r["final"] for r in rest], [False, False, True]) + self.assertFalse(session.cost_paused) + + @tornado.testing.gen_test + async def test_a_request_that_finishes_the_run_does_not_pause_the_session(self): + """Nothing is left to hold back, and the session is complete.""" + self._patch(stats_wire, "STATS_BUDGET_S", 3600) + self._cost_budget(0.02) + await self._load("cg-done") + ws, first = await self._connect("cg-done") + with _slow_stat_queries(0.03): + reply = await self._ask(ws, self._stats(first)["gen"]) + self.assertEqual((reply["type"], reply["final"]), ("stats_update", True)) + session = self._session("cg-done") + self.assertEqual((session.cost_paused, session.stats_status), (False, "complete")) + + @tornado.testing.gen_test + async def test_the_pause_survives_a_generation_bump_a_reconnect_and_a_reload(self): + ws, gen = await self._paused_session("cg-survive") + session = self._session("cg-survive") + + changed = self._stats(await self._state(ws, post_processing="first_three")) + self.assertEqual((changed["status"], changed["reason"], changed["gen"], changed["tier_target"]), + ("not_computed", "cost", gen + 1, "full")) + self.assertTrue(session.cost_paused) + + _, again = await self._connect("cg-survive") + held = session_mod.stats_with_defaults(again["df_meta"]) + self.assertEqual((held["status"], held["reason"], held["tier_target"]), ("not_computed", "cost", "full"), + "an explicit full reports no policy, but a paused one says which tier a forced request continues") + + await self._reload("cg-survive") + reloaded = self._stats(await _read_json(ws)) + self.assertEqual((reloaded["status"], reloaded["reason"], reloaded["gen"]), ("not_computed", "cost", gen + 2)) + refused = await self._ask(ws, gen + 2) + self.assertEqual((refused["type"], refused["reason"]), ("stats_update", "cost")) + + await self._reload("cg-survive", stats_tier="auto") + reset = self._stats(await _read_json(ws)) + self.assertEqual((reset["status"], reset["gen"]), ("pending", gen + 3)) + self.assertFalse(session.cost_paused) + + @tornado.testing.gen_test + async def test_a_new_expression_forgets_the_pause(self): + _ws, _gen = await self._paused_session("cg-new") + session = self._session("cg-new") + other_root = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, other_root, ignore_errors=True) + resp = await _post(self.get_http_port(), "/load_expr", {"session": "cg-new", + "build_dir": _build_stats_wire_dir(other_root), "stats_delivery": "deferred"}) + self.assertEqual(resp.code, 200, resp.body) + self.assertEqual((session.cost_paused, session.stats_status), (False, "pending")) + + @tornado.testing.gen_test + async def test_force_clears_the_pause_and_the_run_goes_on_where_it_stopped(self): + ws, gen = await self._paused_session("cg-resume") + session = self._session("cg-resume") + run = session.stat_runs[(gen, "raw")] + first_unit = list(run.ran) + + reply = await self._ask(ws, gen, force=True) + self.assertEqual((reply["type"], reply["tier"], reply["final"]), ("stats_update", "full", False), reply) + self.assertFalse(session.cost_paused) + self.assertEqual((session.stats_status, session.stats_reason), ("pending", None)) + self.assertEqual((run.ran[:1], len(run.ran)), (first_unit, 2), "the unit that ran is not run again") + + # Back to automatic requests, with a budget no unit exceeds. + self._cost_budget(3600) + rest = await self._ask_to_the_end(ws, gen) + self.assertEqual((rest[-1]["type"], rest[-1]["final"]), ("stats_update", True)) + self.assertEqual((session.stats_status, session.cost_paused), ("complete", False)) + + @tornado.testing.gen_test + async def test_a_client_that_connected_while_paused_is_sent_the_rebuilt_config_when_it_continues(self): + """Its frame was ``not_computed`` and carried the schema tier's config, so the + complete stats have to say it changed, whether the request names the + tier the session is headed for or leaves it out.""" + for index, fields in enumerate(({}, {"tier": "full"})): + sid = f"cg-late-{index}" + _ws, gen = await self._paused_session(sid) + session = self._session(sid) + late, frame = await self._connect(sid) + self.assertEqual({key: self._stats(frame).get(key) for key in ("status", "reason", "tier_target")}, + {"status": "not_computed", "reason": "cost", "tier_target": "full"}, fields) + replies = await self._ask_to_the_end(late, gen, force=True, **fields) + final = replies[-1] + self.assertEqual((final["type"], final["final"], session.stats_status), ("stats_update", True, "complete"), + fields) + for reply in replies[:-1]: + self.assertNotIn("df_display_args", reply, fields) + self.assertIn("df_display_args", final, fields) + self.assertEqual(_as_json(final["df_display_args"]), _as_json(session.df_display_args), fields) + self.assertNotEqual(_as_json(frame["df_display_args"]), _as_json(session.df_display_args), + "the complete stats change the config the paused frame carried") + + @tornado.testing.gen_test + async def test_a_client_that_changed_state_while_paused_is_sent_the_rebuilt_config_when_it_continues(self): + """A state change while paused sends the client a new ``not_computed`` frame, with + the schema tier's config and its own search highlight.""" + ws, _gen = await self._paused_session("cg-search") + session = self._session("cg-search") + frame = await self._state(ws, quick_command_args={"search": ["b"]}, search_string="b") + stats = self._stats(frame) + self.assertEqual((stats["status"], stats["reason"]), ("not_computed", "cost")) + replies = await self._ask_to_the_end(ws, stats["gen"], force=True) + final = replies[-1] + self.assertEqual((final["type"], final["final"], session.stats_status), ("stats_update", True, "complete")) + self.assertIn("df_display_args", final) + self.assertEqual(_as_json(final["df_display_args"]), + _as_json(stats_wire.highlighted_display_args(session.df_display_args, "b"))) + self.assertNotEqual(_as_json(frame["df_display_args"]), _as_json(final["df_display_args"])) + + @tornado.testing.gen_test + async def test_a_client_without_both_bits_is_not_held_by_the_pause(self): + _ws, gen = await self._paused_session("cg-legacy") + session = self._session("cg-legacy") + ws, frame = await self._connect("cg-legacy", caps="stats_update") + self.assertEqual(self._stats(frame)["status"], "pending", + "a client that cannot show the pause is told it is on its way") + ws.write_message(_stats_request(gen)) + reply = await _read_json(ws) + self.assertEqual((reply["type"], reply["tier"], reply["final"]), ("stats_update", "full", True), reply) + self.assertEqual(session.stats_status, "complete") + + @tornado.testing.gen_test + async def test_a_request_with_no_policy_is_never_paused(self): + """An inline session has no policy, so its ondemand client's requests are answered as before.""" + self._cost_budget(0.0) + await self._load("cg-inline", stats_delivery="inline") + ws, frame = await self._connect("cg-inline") + self.assertNotIn("stats", frame["df_meta"]) + with _slow_stat_queries(0.03): + reply = await self._ask(ws, self._session("cg-inline").stats_gen, tier="full", force=True) + self.assertEqual((reply["type"], reply["final"], reply["remaining"]), ("stats_update", True, 0)) + self.assertTrue(_FULL_ONLY_WIRE <= set(_rows_by_stat(reply["payload"]))) + self.assertFalse(self._session("cg-inline").cost_paused) + + +class TestCostGuardUnits: + """The guard on the scalar tier: a chunk of the batch is a unit, and the + first one over the budget pauses the run's session.""" + + def test_a_scalar_chunk_over_the_budget_pauses_the_session_and_force_resumes_it(self, tmp_path, monkeypatch): + monkeypatch.setattr(stats_wire, "STATS_BUDGET_S", 0) + monkeypatch.setattr(stats_wire, "STATS_COST_BUDGET_S", 0.02, raising=False) + # 24 cells at 12 rows is two columns per chunk: three batch units. + session, client = TestScalarTierRequests._session(tmp_path, stat_chunk_cells=24), _ondemand_client() + request = TestScalarTierRequests._request + with _slow_stat_queries(0.05): + opening = request(session, client, incremental=True) + assert (opening["type"], opening["tier"], opening["final"], opening["remaining"]) == ( + "stats_update", "scalar", False, 2), opening + assert session.cost_paused is True + assert (session.stats_status, session.stats_reason) == ("not_computed", "cost") + + with _count_stat_queries() as queries: + refused = request(session, client, incremental=True) + assert queries == [] + assert (refused["type"], refused["status"], refused["reason"], refused["tier"]) == ( + "stats_update", "not_computed", "cost", "scalar") + + forced = request(session, client, incremental=True, tier="scalar", force=True) + assert (forced["type"], forced["tier"], forced["final"], forced["remaining"]) == ("stats_update", "scalar", + False, 1) + assert session.cost_paused is False + assert (session.stats_status, session.stats_reason) == ("not_computed", "size") + last = request(session, client, incremental=True) + assert (last["final"], last["remaining"]) == (True, 0) + assert (last["status"], last["reason"]) == ("not_computed", "size") + + +# A diff-styling klass as the live diff route has one: the numeric colour rule +# is not in the overrides (the route strips it), and the klass adds it at style +# time with the short alias of the delta column as its val_column. +_DELTA_STYLING = ''' +class DeltaStyling(DefaultMainStyling): + df_display_name = "main" + + @classmethod + def style_column(cls, col, column_metadata): + base_config = super().style_column(col, column_metadata) + if column_metadata.get("orig_col_name") == "price": + base_config["color_map_config"] = {"color_rule": "color_map", "map_name": "DIVERGING_RED_WHITE_BLUE", + "val_column": str(col)} + return base_config +''' + +# What remains in the overrides once the live route has stripped the numeric rules. +_CATEGORICAL_OVERRIDE = {"category": {"color_map_config": {"color_rule": "color_categorical", + "map_name": "BLUE_TO_YELLOW"}}} + +# A promoted entry keeps its numeric rule in the overrides, with the delta column +# named as the original column name the host knows. +_NUMERIC_OVERRIDE = {"qty": {"color_map_config": {"color_rule": "color_map", "map_name": "BLUE_TO_YELLOW", + "val_column": "price"}}} + + +class TestDemandScanWire(_LimitsWire): + """The demand scan and the scoped requests it becomes (rows-first p36a): + the columns a built config's ``color_map`` rules need, found with no data + query at load and computed on request, for those columns only.""" + + DISPLAY_FILES = {"delta_styling.py": _DELTA_STYLING} + + @tornado.testing.gen_test + async def test_the_scan_finds_the_delta_column_a_klass_adds_at_style_time(self): + with _count_stat_queries() as queries: + await self._load("dm-klass", stats_tier="schema", column_config_overrides=_CATEGORICAL_OVERRIDE) + self.assertEqual(queries, [], "the scan reads the built config and sends no data query") + session = self._session("dm-klass") + self.assertEqual(session.stats_policy["demand_columns"], ["a"]) + ws, ondemand = await self._connect("dm-klass") + self.assertEqual(self._stats(ondemand)["demand_columns"], ["a"]) + self.assertEqual(self._stats(ondemand)["auto_request"], False) + _, update_only = await self._connect("dm-klass", caps="stats_update") + self.assertNotIn("demand_columns", self._stats(update_only)) + + @tornado.testing.gen_test + async def test_categorical_rules_alone_yield_no_demand(self): + await self._load("dm-categorical", stats_tier="schema", project_root=None, + column_config_overrides=_CATEGORICAL_OVERRIDE) + self.assertEqual(self._session("dm-categorical").stats_policy.get("demand_columns", []), []) + _, frame = await self._connect("dm-categorical") + self.assertNotIn("demand_columns", self._stats(frame)) + + @tornado.testing.gen_test + async def test_a_numeric_rule_in_the_overrides_is_scanned_too(self): + await self._load("dm-override", stats_tier="schema", project_root=None, + column_config_overrides=_NUMERIC_OVERRIDE) + _, frame = await self._connect("dm-override") + self.assertEqual(self._stats(frame)["demand_columns"], ["a"], + "the host's name for the column is the stats' key") + + @tornado.testing.gen_test + async def test_a_session_that_will_compute_the_columns_anyway_has_no_demand(self): + for index, (tier, limits) in enumerate((("scalar", {}), ("auto", {}), ("auto", _SCALAR_BY_SIZE))): + with _stats_limits(**limits): + sid = f"dm-covered-{index}" + await self._load(sid, stats_tier=tier) + _, frame = await self._connect(sid) + self.assertNotIn("demand_columns", self._stats(frame), (tier, limits)) + + @tornado.testing.gen_test + async def test_the_demand_follows_a_state_change_and_a_reload(self): + await self._load("dm-follow", stats_tier="schema", column_config_overrides=_CATEGORICAL_OVERRIDE) + ws, first = await self._connect("dm-follow") + changed = self._stats(await self._state(ws, post_processing="first_three")) + self.assertEqual(changed["demand_columns"], ["a"]) + await self._reload("dm-follow") + self.assertEqual(self._stats(await _read_json(ws))["demand_columns"], ["a"]) + + @tornado.testing.gen_test + async def test_a_demand_request_runs_the_scalar_units_of_the_named_columns_only(self): + await self._load("dm-run", stats_tier="schema", column_config_overrides=_CATEGORICAL_OVERRIDE) + session = self._session("dm-run") + ws, first = await self._connect("dm-run") + stats = self._stats(first) + held = (session.df_data_dict, session.df_display_args, session.df_meta) + + with _count_stat_queries() as queries: + reply = await self._ask(ws, stats["gen"], tier="scalar", columns=stats["demand_columns"]) + + self.assertEqual((reply["type"], reply["tier"], reply["final"], reply["remaining"]), ("stats_update", "scalar", + True, 0), reply) + self.assertEqual((reply["status"], reply["reason"]), ("not_computed", "host")) + rows = _rows_by_stat(reply["payload"]) + self.assertEqual(set(rows), _SCALAR_WIRE_ONLY) + self.assertEqual({c for row in rows.values() for c in row} - {"index", "level_0"}, {"a"}) + self.assertEqual(len(rows["histogram_bins"]["a"]), 11, "what the colour map reads") + self.assertEqual([type(q.op()).__name__ for q in queries], ["Aggregate"]) + self.assertEqual({name.split("|")[0] for name in queries[0].op().metrics} - {"__total_length__"}, {"price"}) + self.assertEqual(list(session.stat_runs), [(stats["gen"], "raw", "scalar", ("price",))]) + self.assertTrue(all(a is b for a, b in zip(held, (session.df_data_dict, session.df_display_args, + session.df_meta))), "nothing is assigned") + self.assertEqual((session.stats_status, session.stats_reason), ("not_computed", "host")) + + # Another client asking for the same columns reads the run's fragments. + other, other_first = await self._connect("dm-run") + with _count_stat_queries() as queries: + replay = await self._ask(other, self._stats(other_first)["gen"], tier="scalar", columns=["a"]) + self.assertEqual(queries, []) + self.assertEqual(_as_json(_rows_by_stat(replay["payload"])), _as_json(rows)) + + @tornado.testing.gen_test + async def test_a_named_columns_request_is_not_forced_and_needs_no_requestable_tier(self): + """A demand request is automatic. The whole table's tier above a schema target needs a force.""" + with _stats_limits(**{**_SCHEMA_BY_SIZE, "ceiling_full_rows": 3}): + await self._load("dm-auto", stats_tier="auto", column_config_overrides=_CATEGORICAL_OVERRIDE) + ws, first = await self._connect("dm-auto") + gen = self._stats(first)["gen"] + scoped = await self._ask(ws, gen, tier="scalar", columns=["b"]) + self.assertEqual((scoped["type"], scoped["tier"], scoped["final"]), ("stats_update", "scalar", True), + scoped) + whole = await self._ask(ws, gen, tier="scalar") + self.assertEqual((whole["type"], whole["reason"]), ("stats_aborted", "not_requestable")) + + @tornado.testing.gen_test + async def test_the_ceiling_judges_a_named_columns_request_by_its_own_cells(self): + """The table is 15 cells and the ceiling on scalar is 10: two columns fit, three do not.""" + with _stats_limits(ceiling_scalar_cells=10): + await self._load("dm-cells", stats_tier="schema") + ws, first = await self._connect("dm-cells") + gen = self._stats(first)["gen"] + self.assertEqual(self._stats(first)["requestable"], []) + fits = await self._ask(ws, gen, tier="scalar", columns=["a", "b"]) + self.assertEqual((fits["type"], fits["final"]), ("stats_update", True), fits) + self.assertEqual({c for row in _rows_by_stat(fits["payload"]).values() for c in row} - {"index", "level_0"}, + {"a", "b"}) + over = await self._ask(ws, gen, tier="scalar", columns=["a", "b", "c"]) + self.assertEqual((over["type"], over["reason"]), ("stats_update", "ceiling")) + + @tornado.testing.gen_test + async def test_a_named_columns_request_at_the_full_tier_runs_those_columns_units_and_assigns_nothing(self): + self._one_unit_per_request() + await self._load("dm-full", stats_tier="schema") + session = self._session("dm-full") + ws, first = await self._connect("dm-full") + gen = self._stats(first)["gen"] + replies = await self._ask_to_the_end(ws, gen, tier="full", columns=["b"]) + self.assertEqual([(r["type"], r["tier"], r["final"], r["remaining"]) for r in replies], + [("stats_update", "full", False, 1), ("stats_update", "full", True, 0)]) + self.assertEqual((replies[-1]["status"], replies[-1]["reason"]), ("not_computed", "host")) + merged = _merge_stat_payloads(r["payload"] for r in replies) + self.assertTrue(_FULL_ONLY_WIRE <= set(merged)) + self.assertEqual({c for row in merged.values() for c in row}, {"b"}) + self.assertEqual(list(session.stat_runs), [(gen, "raw", "full", ("qty",))]) + self.assertEqual((session.stats_status, session.xorq_dataflow.stats_tier), ("not_computed", "schema")) + + @tornado.testing.gen_test + async def test_two_requests_for_the_same_columns_in_any_order_share_a_run(self): + await self._load("dm-share", stats_tier="schema") + session = self._session("dm-share") + ws, first = await self._connect("dm-share") + gen = self._stats(first)["gen"] + await self._ask(ws, gen, tier="scalar", columns=["b", "a", "a"]) + with _count_stat_queries() as queries: + await self._ask(ws, gen, tier="scalar", columns=["a", "b"]) + self.assertEqual(queries, []) + self.assertEqual(list(session.stat_runs), [(gen, "raw", "scalar", ("price", "qty"))]) + + @tornado.testing.gen_test + async def test_a_named_columns_request_on_a_complete_session_is_answered_from_the_dataflow(self): + await self._load("dm-complete", stats_tier="auto") + ws, first = await self._connect("dm-complete") + gen = self._stats(first)["gen"] + done = await self._ask_to_the_end(ws, gen, tier="full", force=True) + self.assertTrue(done[-1]["final"]) + self.assertEqual(self._session("dm-complete").stats_status, "complete") + with _count_stat_queries() as queries: + again = await self._ask(ws, gen, tier="scalar", columns=["a"]) + self.assertEqual((again["type"], again["final"], again["tier"]), ("stats_update", True, "full")) + self.assertEqual(queries, []) + + +class TestTierFieldsAndOlderClients(_LimitsWire): + """``tier``, ``force`` and a ``columns`` that scopes the run belong to a + client that advertised ``stats_ondemand``; every other client is served as + before.""" + + @tornado.testing.gen_test + async def test_a_client_with_stats_update_only_has_the_fields_ignored(self): + with _stats_limits(**_SCHEMA_BY_SIZE): + await self._load("sk-update", stats_tier="auto") + ws, first = await self._connect("sk-update", caps="stats_update") + gen = self._stats(first)["gen"] + self.assertEqual(self._stats(first)["status"], "pending") + ws.write_message(_stats_request(gen, tier="scalar", force=True, columns=["a"])) + reply = await _read_json(ws) + self.assertEqual((reply["type"], reply["tier"], reply["final"]), ("stats_update", "full", True), reply) + self.assertNotIn("status", reply) + self.assertTrue(_FULL_ONLY_WIRE <= set(_rows_by_stat(reply["payload"]))) + + @tornado.testing.gen_test + async def test_force_from_a_client_without_both_bits_records_no_override(self): + with _stats_limits(**_SCHEMA_BY_SIZE): + await self._load("sk-no-override", stats_tier="auto") + ws, first = await self._connect("sk-no-override", caps="stats_update") + ws.write_message(_stats_request(self._stats(first)["gen"], tier="full", force=True)) + reply = await _read_json(ws) + self.assertEqual((reply["type"], reply["final"]), ("stats_update", True)) + session = self._session("sk-no-override") + self.assertEqual((session.stats_override, session.cost_paused), (None, False)) + + @tornado.testing.gen_test + async def test_a_host_named_schema_session_refuses_a_stats_update_only_client_whatever_it_sends(self): + await self._load("sk-host", stats_tier="schema") + ws, first = await self._connect("sk-host", caps="stats_update") + gen = self._stats(first)["gen"] + for fields in ({}, {"tier": "full", "force": True}): + ws.write_message(_stats_request(gen, incremental=True, **fields)) + aborted = await _read_json(ws) + self.assertEqual((aborted["type"], aborted["reason"]), ("stats_aborted", "not_requestable"), fields) + + @tornado.testing.gen_test + async def test_a_session_with_no_policy_ignores_the_fields_of_an_ondemand_client(self): + await self._load("sk-inline", stats_delivery="inline") + ws, frame = await self._connect("sk-inline") + self.assertNotIn("stats", frame["df_meta"]) + gen = self._session("sk-inline").stats_gen + for fields in ({}, {"tier": "scalar"}, {"tier": "full", "force": True}, {"tier": "scalar", "columns": ["a"]}, + {"tier": "medium"}, {"force": "yes"}, {"tier": "scalar", "columns": ["zz"]}): + reply = await self._ask(ws, gen, **fields) + self.assertEqual((reply["type"], reply["final"], reply["tier"]), ("stats_update", True, "full"), fields) + self.assertTrue(_FULL_ONLY_WIRE <= set(_rows_by_stat(reply["payload"])), fields) + self.assertEqual(self._session("sk-inline").stat_runs, {}) + + @tornado.testing.gen_test + async def test_an_explicit_full_session_serves_an_ondemand_client_its_plain_requests_as_before(self): + self._one_unit_per_request() + await self._load("sk-full", stats_tier="full") + ws, first = await self._connect("sk-full") + self.assertEqual(self._stats(first), {"status": "pending", "tier": "schema", "gen": self._stats(first)["gen"]}) + replies = await self._ask_to_the_end(ws, self._stats(first)["gen"]) + self.assertEqual([(r["tier"], r["final"], r["remaining"]) for r in replies], + [("full", False, 3), ("full", False, 2), ("full", False, 1), ("full", True, 0)]) + self.assertTrue(all("status" not in r for r in replies)) + + +# --------------------------------------------------------------------------- +# Guards for huge sources (rows-first p37) +# --------------------------------------------------------------------------- + +# A display klass that turns sorting back on for every column, as a klass that +# sets its own ag_grid_specs might. +_SORTABLE_STYLING = ''' +class SortableStyling(DefaultMainStyling): + df_display_name = "main" + + @classmethod + def style_column(cls, col, column_metadata): + base_config = super().style_column(col, column_metadata) + base_config["ag_grid_specs"] = {**base_config.get("ag_grid_specs", {}), "sortable": True, "minWidth": 90} + return base_config +''' + +# The same from the host's side: an override applied after the klasses have styled. +_SORTABLE_OVERRIDE = {"qty": {"ag_grid_specs": {"sortable": True}}} + + +@contextmanager +def _guard_limits(**limits): + """Sort and search thresholds for the block, as ``BUCKAROO_*`` environment + overrides, which are read each time a load resolves its guards. The table + ``_build_stats_wire_dir`` builds has five rows, so a ``sort_disable_rows`` of + 3 puts it above the sort threshold.""" + env = {f"BUCKAROO_{name.upper()}": str(value) for name, value in limits.items()} + with patch.dict(os.environ, env): + yield + + +@contextmanager +def _count_backend_queries(): + """Record the outermost op of every query sent to the backend (``execute`` + and ``to_pyarrow``) while the block runs: a count is ``CountStar``.""" + queries = [] + original_execute, original_to_pyarrow = Expr.execute, Expr.to_pyarrow + + def execute(self, *args, **kwargs): + queries.append(type(self.op()).__name__) + return original_execute(self, *args, **kwargs) + + def to_pyarrow(self, *args, **kwargs): + queries.append(type(self.op()).__name__) + return original_to_pyarrow(self, *args, **kwargs) + + with patch.object(Expr, "execute", execute), patch.object(Expr, "to_pyarrow", to_pyarrow): + yield queries + + +def _grid_columns(frame, display="main"): + """Every column config the grid of ``display`` is built from, the index + columns included.""" + config = frame["df_display_args"][display]["df_viewer_config"] + return config["column_config"] + config["left_col_configs"] + + +def _sortable(column): + return column.get("ag_grid_specs", {}).get("sortable") + + +class TestSortGuardDataflow: + """The sort guard on the dataflow (rows-first p37): ``set_sort_enabled`` turns + sorting off in the display config as a final pass, after the klasses and the + host's overrides, and the state of the dataflow survives a rebuild.""" + + @staticmethod + def _main(dataflow): + config = dataflow.df_display_args["main"]["df_viewer_config"] + return config["column_config"] + config["left_col_configs"] + + def test_a_dataflow_sorts_by_default(self): + dataflow = _build_dataflow() + assert dataflow.sort_enabled is True + assert all(_sortable(c) is not False for c in self._main(dataflow)) + + def test_disabling_sorting_covers_every_column_including_one_an_override_sets(self): + dataflow = _build_dataflow(column_config_overrides=_SORTABLE_OVERRIDE) + assert [_sortable(c) for c in self._main(dataflow) if c.get("header_name") == "qty"] == [True] + dataflow.set_sort_enabled(False) + columns = self._main(dataflow) + assert len(columns) == 4 + assert [_sortable(c) for c in columns] == [False] * 4 + + def test_the_summary_display_is_not_changed(self): + dataflow = _build_dataflow() + before = json.dumps(dataflow.df_display_args["summary"], sort_keys=True) + dataflow.set_sort_enabled(False) + assert json.dumps(dataflow.df_display_args["summary"], sort_keys=True) == before + + def test_enabling_sorting_again_restores_the_config(self): + dataflow = _build_dataflow() + before = json.dumps(dataflow.df_display_args, sort_keys=True) + dataflow.set_sort_enabled(False) + assert json.dumps(dataflow.df_display_args, sort_keys=True) != before + dataflow.set_sort_enabled(True) + assert json.dumps(dataflow.df_display_args, sort_keys=True) == before + + def test_the_guard_survives_a_state_change_and_new_stats(self): + dataflow = _build_dataflow(stats_tier="schema") + dataflow.set_sort_enabled(False) + dataflow.quick_command_args = {"search": ["a"]} + assert [_sortable(c) for c in self._main(dataflow)] == [False] * 4 + stats_wire.assign_full_stats(dataflow) + assert [_sortable(c) for c in self._main(dataflow)] == [False] * 4 + + +class TestSortGuardWire(_LimitsWire): + """A session a host opened with a stats policy, over the sort threshold + (rows-first p37): sorting is off in its config, a sorted window is refused + with ``sort_disabled``, and ``df_meta`` says so. The table has five rows, so a + ``sort_disable_rows`` of 3 is over.""" + + DISPLAY_FILES = {"sortable_styling.py": _SORTABLE_STYLING} + + async def _window(self, ws, **fields): + ws.write_message(json.dumps({"type": "infinite_request", "payload_args": {"start": 0, "end": 5, + "sourceName": "default", "origEnd": 5, **fields}})) + return await _read_json(ws) + + async def _served(self, ws, **fields): + """A window the server serves: the ``infinite_resp``, then its parquet frame.""" + resp = await self._window(ws, **fields) + self.assertEqual((resp["type"], resp.get("error_info")), ("infinite_resp", None), resp) + self.assertIsInstance(await ws.read_message(), bytes) + return resp + + async def _refused(self, ws, rows=5, **fields): + """A window the server refuses, with nothing after it: the next frame + read is the answer to the next request, not a parquet frame. ``rows`` is + the length of that next answer.""" + resp = await self._refusal(ws, **fields) + again = await self._window(ws) + self.assertEqual((again["type"], again["length"]), ("infinite_resp", rows), again) + self.assertIsInstance(await ws.read_message(), bytes) + return resp + + async def _refusal(self, ws, **fields): + resp = await self._window(ws, **fields) + self.assertEqual((resp["type"], resp.get("error_code")), ("infinite_resp", "sort_disabled"), resp) + return resp + + @tornado.testing.gen_test + async def test_every_column_stops_sorting_whatever_a_klass_or_an_override_says(self): + with _guard_limits(sort_disable_rows=3): + await self._load("sg-columns", column_config_overrides=_SORTABLE_OVERRIDE) + _, frame = await self._connect("sg-columns") + columns = _grid_columns(frame) + self.assertEqual(len(columns), 4) + self.assertEqual([_sortable(c) for c in columns], [False] * 4, columns) + by_name = {c["header_name"]: c["ag_grid_specs"] for c in columns if "header_name" in c and c["col_name"] != "index"} + self.assertEqual(by_name["price"], {"minWidth": 90, "sortable": False}, "the klass's other specs are kept") + self.assertEqual(by_name["qty"], {"sortable": False}, + "the override replaced the specs, and the guard has the last word") + self.assertEqual(frame["df_display_args"], self._session("sg-columns").df_display_args) + + @tornado.testing.gen_test + async def test_a_session_built_the_old_way_is_not_guarded(self): + """No stats policy, no guard: inline stats is what every host gets today.""" + with _guard_limits(sort_disable_rows=3): + await self._load("sg-inline", stats_delivery="inline") + ws, frame = await self._connect("sg-inline") + self.assertNotIn("sort", frame["df_meta"]) + self.assertTrue(all(_sortable(c) is not False for c in _grid_columns(frame))) + await self._served(ws, sort="a", sort_direction="asc") + + @tornado.testing.gen_test + async def test_a_session_under_both_thresholds_sends_no_flags(self): + with _guard_limits(sort_disable_rows=100, search_disable_rows=100): + await self._load("sg-none") + _, frame = await self._connect("sg-none") + self.assertFalse({"sort", "search"} & set(frame["df_meta"]), frame["df_meta"]) + + @tornado.testing.gen_test + async def test_a_session_within_the_threshold_is_as_it_was(self): + with _guard_limits(sort_disable_rows=5): + await self._load("sg-within") + ws, frame = await self._connect("sg-within") + self.assertNotIn("sort", frame["df_meta"]) + self.assertTrue(all(_sortable(c) is not False for c in _grid_columns(frame))) + self.assertEqual([_sortable(c) for c in frame["df_display_args"]["main"]["df_viewer_config"]["column_config"]], + [True] * 3, "the klass's own setting stands") + await self._served(ws, sort="a", sort_direction="asc") + + @tornado.testing.gen_test + async def test_the_summary_display_still_sorts(self): + with _guard_limits(sort_disable_rows=3): + await self._load("sg-summary") + _, frame = await self._connect("sg-summary") + self.assertTrue(_grid_columns(frame, "summary")) + self.assertTrue(all(_sortable(c) is not False for c in _grid_columns(frame, "summary"))) + + @tornado.testing.gen_test + async def test_unsorted_and_searched_windows_are_served(self): + with _guard_limits(sort_disable_rows=3): + await self._load("sg-serve") + ws, _ = await self._connect("sg-serve") + self.assertEqual((await self._served(ws))["length"], 5) + await self._state(ws, search_string="a") + self.assertEqual((await self._served(ws))["length"], 2) + + + @tornado.testing.gen_test + async def test_a_sorted_window_is_refused_and_no_query_runs(self): + with _guard_limits(sort_disable_rows=3): + await self._load("sg-refuse") + ws, _ = await self._connect("sg-refuse") + with _count_backend_queries() as queries: + refusal = await self._refusal(ws, sort="a", sort_direction="asc") + self.assertEqual(queries, []) + self.assertEqual(refusal["length"], 0) + self.assertEqual(refusal["key"]["sort"], "a") + self.assertTrue(isinstance(refusal["error_info"], str) and refusal["error_info"]) + again = await self._window(ws) + self.assertEqual((again["type"], again["length"]), ("infinite_resp", 5), again) + self.assertIsInstance(await ws.read_message(), bytes, "no frame followed the refusal") + + @tornado.testing.gen_test + async def test_the_second_window_of_a_request_is_judged_too(self): + with _guard_limits(sort_disable_rows=3): + await self._load("sg-second") + ws, _ = await self._connect("sg-second") + second = {"start": 5, "end": 10, "sourceName": "default", "origEnd": 10, "sort": "a", "sort_direction": "asc"} + first = await self._window(ws, second_request=second) + self.assertEqual((first["type"], first.get("error_info")), ("infinite_resp", None), first) + self.assertIsInstance(await ws.read_message(), bytes) + refusal = await _read_json(ws) + self.assertEqual((refusal["type"], refusal["error_code"], refusal["key"]), ("infinite_resp", "sort_disabled", + second)) + + + @tornado.testing.gen_test + async def test_df_meta_carries_the_sort_flag_for_every_client(self): + with _guard_limits(sort_disable_rows=3): + await self._load("sg-meta") + for caps in (_ONDEMAND, "stats_update", None): + _, frame = await self._connect("sg-meta", caps=caps) + self.assertEqual(frame["df_meta"].get("sort"), "disabled", caps) + self.assertNotIn("search", frame["df_meta"], caps) + + @tornado.testing.gen_test + async def test_df_meta_carries_the_search_flag_when_its_threshold_is_passed_and_search_is_still_served(self): + with _guard_limits(sort_disable_rows=100, search_disable_rows=3): + await self._load("sg-search") + ws, frame = await self._connect("sg-search") + self.assertEqual(frame["df_meta"].get("search"), "disabled") + self.assertNotIn("sort", frame["df_meta"]) + self.assertTrue(all(_sortable(c) is not False for c in _grid_columns(frame))) + await self._state(ws, search_string="a") + self.assertEqual((await self._served(ws))["length"], 2, "the server has no switch for search") + + + @tornado.testing.gen_test + async def test_the_guard_follows_a_state_change(self): + with _guard_limits(sort_disable_rows=3): + await self._load("sg-state") + ws, _ = await self._connect("sg-state") + frame = await self._state(ws, post_processing="first_three") + self.assertEqual(frame["df_meta"].get("sort"), "disabled") + self.assertEqual([_sortable(c) for c in _grid_columns(frame)], [False] * 4) + await self._refused(ws, rows=3, sort="a", sort_direction="desc") + + @tornado.testing.gen_test + async def test_the_guard_holds_once_the_stats_are_complete(self): + with _guard_limits(sort_disable_rows=3): + await self._load("sg-complete") + ws, first = await self._connect("sg-complete") + replies = await self._ask_to_the_end(ws, self._stats(first)["gen"]) + self.assertTrue(replies[-1]["final"]) + session = self._session("sg-complete") + self.assertEqual(session.stats_status, "complete") + config = session.df_display_args["main"]["df_viewer_config"] + self.assertEqual([_sortable(c) for c in config["column_config"] + config["left_col_configs"]], [False] * 4) + if "df_display_args" in replies[-1]: + held = replies[-1]["df_display_args"]["main"]["df_viewer_config"] + self.assertEqual([_sortable(c) for c in held["column_config"] + held["left_col_configs"]], [False] * 4) + await self._refused(ws, sort="b", sort_direction="asc") + + @tornado.testing.gen_test + async def test_a_reload_resolves_the_guard_again(self): + with _guard_limits(sort_disable_rows=100): + await self._load("sg-reload") + ws, first = await self._connect("sg-reload") + self.assertNotIn("sort", first["df_meta"]) + with _guard_limits(sort_disable_rows=3): + await self._reload("sg-reload") + frame = await _read_json(ws) + self.assertEqual(frame["df_meta"].get("sort"), "disabled") + self.assertEqual([_sortable(c) for c in _grid_columns(frame)], [False] * 4) + await self._refused(ws, sort="a", sort_direction="asc") + with _guard_limits(sort_disable_rows=100): + await self._reload("sg-reload") + frame = await _read_json(ws) + self.assertNotIn("sort", frame["df_meta"]) + self.assertTrue(all(_sortable(c) is not False for c in _grid_columns(frame))) + await self._served(ws, sort="a", sort_direction="asc") + + @tornado.testing.gen_test + async def test_a_load_of_a_file_ends_the_guard(self): + sid = "sg-load" + with _guard_limits(sort_disable_rows=3): + await self._load(sid) + ws, _ = await self._connect(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) + frame = await _read_json(ws) + self.assertNotIn("sort", frame["df_meta"]) + self.assertIsNone(getattr(self._session(sid), "source_guards", "missing")) + self.assertEqual((await self._served(ws, sort="a", sort_direction="asc"))["length"], 3) + + @tornado.testing.gen_test + async def test_a_comparison_ends_the_guard(self): + sid = "sg-compare" + with _guard_limits(sort_disable_rows=3): + await self._load(sid) + ws, _ = await self._connect(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) + frame = await _read_json(ws) + self.assertNotIn("sort", frame["df_meta"]) + self.assertIsNone(getattr(self._session(sid), "source_guards", "missing")) + + +class TestSearchedCountMemo: + """A searched xorq window counts the filtered rows, and ``search_expr`` builds + a new expression each time, which ``_expr_count`` caches by object, so every + request counted again. The count is now held for (base expression, term) + (rows-first p37, plan 2 section 4.3).""" + + WINDOW = {"start": 0, "end": 5, "sourceName": "default", "origEnd": 5} + + @staticmethod + def _counts(queries): + return queries.count("CountStar") + + def test_a_repeated_searched_window_issues_one_count(self): + dataflow = _build_dataflow() + with _count_backend_queries() as queries: + first, _ = xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string="a") + second, _ = xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string="a") + assert (first["length"], second["length"]) == (2, 2) + assert self._counts(queries) == 1 + + def test_another_window_of_the_same_search_reuses_the_count(self): + dataflow = _build_dataflow() + with _count_backend_queries() as queries: + for start, end in ((0, 1), (1, 2), (0, 2)): + resp, parquet = xorq_loading.handle_infinite_request_xorq(dataflow, + {**self.WINDOW, "start": start, "end": end}, search_string="a") + assert (resp["length"], pq.read_table(io.BytesIO(parquet)).num_rows) == (2, end - start) + assert self._counts(queries) == 1 + + def test_each_term_is_counted_once(self): + dataflow = _build_dataflow() + with _count_backend_queries() as queries: + lengths = [xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string=term)[0]["length"] + for term in ("a", "b", "a", "b", "c", "a")] + assert lengths == [2, 2, 2, 2, 1, 2] + assert self._counts(queries) == 3 + + def test_a_new_base_expression_is_counted_again(self): + dataflow = _build_dataflow() + before, _ = xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string="a") + dataflow.quick_command_args = {"search": ["b"]} + with _count_backend_queries() as queries: + after, _ = xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string="a") + xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string="a") + assert (before["length"], after["length"]) == (2, 0), "the rows the quick search left hold no a" + assert self._counts(queries) == 1 + + @staticmethod + def _terms_per_base(): + per_base = getattr(xorq_loading, "_SEARCHED_EXPRS_PER_BASE", None) + assert per_base is not None, "xorq_loading._SEARCHED_EXPRS_PER_BASE does not exist" + return per_base + + def test_a_base_keeps_a_bounded_number_of_terms(self): + dataflow = _build_dataflow() + terms = [f"t{i}" for i in range(self._terms_per_base() + 5)] + for term in terms: + xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string=term) + with _count_backend_queries() as queries: + xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string=terms[-1]) + assert self._counts(queries) == 0, "the latest term is held" + xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string=terms[0]) + assert self._counts(queries) == 1, "the oldest was dropped to make room" + + def test_a_term_asked_again_is_the_last_to_go(self): + dataflow = _build_dataflow() + terms = [f"t{i}" for i in range(self._terms_per_base())] + for term in terms: + xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string=term) + # t0 is the oldest of a full base; asking for it again makes it the newest, + # so the next new term takes t1's place. + xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string=terms[0]) + xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string="newcomer") + with _count_backend_queries() as queries: + xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string=terms[0]) + assert self._counts(queries) == 0, "a term that was asked again is still held" + xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string=terms[1]) + assert self._counts(queries) == 1, "the least recently asked term was dropped" + + + def test_a_window_with_no_term_counts_nothing_new(self): + dataflow = _build_dataflow() + with _count_backend_queries() as queries: + for _ in range(3): + resp, _ = xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string="") + assert resp["length"] == 5 + assert self._counts(queries) == 0 + + def test_a_count_that_failed_is_not_remembered(self): + dataflow = _build_dataflow() + failures = [RuntimeError("the backend is down")] + original = Expr.execute + + def execute(expr, *args, **kwargs): + if type(expr.op()).__name__ == "CountStar" and failures: + raise failures.pop() + return original(expr, *args, **kwargs) + + with patch.object(Expr, "execute", execute): + first, _ = xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string="a") + second, _ = xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string="a") + assert (first["length"], second["length"]) == (0, 2), "the next request counts again and gets the real count" + @pytest.mark.parametrize("table", [{"x": [1, 2, 3], "name": ["a", "b", "a"]}, {"x": [1, 2, 3]}], + ids=["with a string column", "no string column"]) + def test_the_memo_does_not_keep_the_base_expression_alive(self, table): + dataflow = _build_dataflow(xo.memtable(table, name="t")) + resp, _ = xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string="a") + assert resp["length"] == (2 if "name" in table else 3) + base = weakref.ref(dataflow.widget_args_tuple[1]) + del dataflow + gc.collect() + assert base() is None + + def test_a_source_with_no_string_column_is_not_searched_and_counts_nothing_new(self): + dataflow = _build_dataflow(xo.memtable({"x": [1, 2, 3]}, name="t")) + with _count_backend_queries() as queries: + lengths = [xorq_loading.handle_infinite_request_xorq(dataflow, self.WINDOW, search_string="a")[0]["length"] + for _ in range(2)] + assert lengths == [3, 3] + assert self._counts(queries) == 0 + + +class TestSearchedWindowCountsWire(_LimitsWire): + """The same through the WebSocket: the term a client types is per client, and + the count it costs is shared.""" + + async def _window(self, ws, **fields): + ws.write_message(json.dumps({"type": "infinite_request", "payload_args": {"start": 0, "end": 5, + "sourceName": "default", "origEnd": 5, **fields}})) + resp = await _read_json(ws) + self.assertIsInstance(await ws.read_message(), bytes) + return resp + + @tornado.testing.gen_test + async def test_a_repeated_searched_window_issues_one_count(self): + await self._load("sc-repeat", stats_delivery="inline") + ws, _ = await self._connect("sc-repeat", caps=None) + await self._state(ws, search_string="a") + with _count_backend_queries() as queries: + lengths = [(await self._window(ws, start=start, end=end))["length"] for start, end in ((0, 5), (0, 5), (1, 2))] + self.assertEqual(lengths, [2, 2, 2]) + self.assertEqual(queries.count("CountStar"), 1) + + @tornado.testing.gen_test + async def test_two_clients_searching_the_same_term_share_the_count(self): + await self._load("sc-share", stats_delivery="inline") + a, _ = await self._connect("sc-share", caps=None) + b, _ = await self._connect("sc-share", caps=None) + for ws in (a, b): + await self._state(ws, search_string="a") + with _count_backend_queries() as queries: + self.assertEqual([(await self._window(ws))["length"] for ws in (a, b, a, b)], [2, 2, 2, 2]) + self.assertEqual(queries.count("CountStar"), 1) + + class TestReloadExpr(tornado.testing.AsyncHTTPTestCase): def get_app(self): return make_app() diff --git a/tests/unit/server/test_server.py b/tests/unit/server/test_server.py index b1b8f9032..f8fc2d7f7 100644 --- a/tests/unit/server/test_server.py +++ b/tests/unit/server/test_server.py @@ -11,6 +11,7 @@ import tornado.websocket from buckaroo.server.app import make_app as _make_app +from buckaroo.server.session import build_state_message # Temp file cleanup fails on Windows due to file locking (WinError 32) pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="Temp file locking prevents cleanup on Windows") @@ -242,6 +243,46 @@ def test_load_buckaroo_without_optional_configs_keeps_defaults(self): finally: os.unlink(f.name) + def test_load_does_not_take_stats_tier_and_resolves_to_full(self): + """/load serves the eager backends, whose stats cost does not grow with + the file, so they resolve to full: the request fields are not read and + a policy left on the session by an earlier /load_expr is dropped + (rows-first p33).""" + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f: + _write_test_csv(f.name) + try: + body = {"session": "stats-tier-1", "path": f.name, "mode": "buckaroo", "stats_tier": "schema", + "stats_delivery": "deferred"} + for _ in range(2): + resp = self.fetch("/load", method="POST", body=json.dumps(body), + headers={"Content-Type": "application/json"}) + self.assertEqual(resp.code, 200) + session = self._app.settings["sessions"].get("stats-tier-1") + self.assertEqual((session.stats_tier, session.stats_delivery), ("full", "inline")) + self.assertIsNone(session.stats_policy) + self.assertEqual(session.stats_status, "complete") + self.assertNotIn("stats", build_state_message(session)["df_meta"]) + # A /load_expr that came first would have left a policy behind. + session.stats_policy = {"tier_target": "schema", "reason": "size"} + finally: + os.unlink(f.name) + + def test_load_drops_a_stat_chunk_cells_left_by_load_expr(self): + """The count belongs to the xorq stats batch, which /load does not run: a + session that /load_expr gave one starts over (rows-first s6).""" + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f: + _write_test_csv(f.name) + try: + session = self._app.settings["sessions"].create("chunk-cells-1", "") + session.stat_chunk_cells = 60_000_000 + resp = self.fetch("/load", method="POST", + body=json.dumps({"session": "chunk-cells-1", "path": f.name, "mode": "buckaroo"}), + headers={"Content-Type": "application/json"}) + self.assertEqual(resp.code, 200) + self.assertIsNone(session.stat_chunk_cells) + finally: + os.unlink(f.name) + class TestSessionPage(tornado.testing.AsyncHTTPTestCase): def get_app(self): diff --git a/tests/unit/server/test_stats_policy.py b/tests/unit/server/test_stats_policy.py new file mode 100644 index 000000000..8ef6c0192 --- /dev/null +++ b/tests/unit/server/test_stats_policy.py @@ -0,0 +1,1649 @@ +"""Unit tests for ``buckaroo.server.stats_policy`` (rows-first p31, p31b). + +Everything here is pure Python: the policy is a function of numbers, the +probes read a schema or a parquet footer, and nothing needs a server. The last +sections (rows-first p33 and p36a) test how the session carries the result and how +``df_meta.stats`` reports it, and how a ``stats_request`` is judged against it (the +ceiling, ``requestable``, ``force``, the cost pause, the policy a forced tier +raises it to, and the demand scan), still without a server; the HTTP and +WebSocket side is in test_load_expr.py. The last section (rows-first p37) tests the +guards for huge sources: the sort and search thresholds, the flags a session +reports, the refusal of a sorted window, and the pass that turns sorting off in a +display config. + +The thresholds are the provisional values proposed by the phase-0 +measurements (p31b). The boundary tables run on the module defaults, so a +change to a ``DEFAULT_*`` constant fails them and the table is updated +together with the constant. ``TestCalibratedDefaults`` pins each default to +its literal value. +""" +import dataclasses +import importlib +import importlib.util +import io +import json +import logging +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import polars as pl +import pyarrow as pa +import pyarrow.parquet as pq +import pytest + +from buckaroo.jlisp.lisp_utils import s as lisp_sym +from buckaroo.jlisp.lisp_utils import sQ +from buckaroo.server import session as session_mod +from buckaroo.server import stats_wire + +ENV_FIELDS = {"BUCKAROO_STATS_FULL_AUTO_ROWS": "full_auto_rows", "BUCKAROO_STATS_FULL_AUTO_CELLS": "full_auto_cells", + "BUCKAROO_STATS_SCALAR_AUTO_CELLS": "scalar_auto_cells", "BUCKAROO_STATS_CEILING_FULL_ROWS": "ceiling_full_rows", + "BUCKAROO_STATS_CEILING_FULL_CELLS": "ceiling_full_cells", + "BUCKAROO_STATS_CEILING_SCALAR_CELLS": "ceiling_scalar_cells", "BUCKAROO_POLARS_ROUTE_ROWS": "polars_route_rows"} + +# The phase-0 values (measurements-phase0.md, "Proposed values"), written out +# so a change to a module default has to change a test too. +CALIBRATED = {"full_auto_rows": 12_000_000, "full_auto_cells": 520_000_000, "scalar_auto_cells": 1_000_000_000, + "ceiling_full_rows": 25_000_000, "ceiling_full_cells": 1_000_000_000, "ceiling_scalar_cells": 4_000_000_000, + "polars_route_rows": 8_000_000} +CALIBRATED_CONSTANTS = {"DEFAULT_FULL_AUTO_ROWS": 12_000_000, "DEFAULT_FULL_AUTO_CELLS": 520_000_000, + "DEFAULT_SCALAR_AUTO_CELLS": 1_000_000_000, "DEFAULT_CEILING_FULL_ROWS": 25_000_000, + "DEFAULT_CEILING_FULL_CELLS": 1_000_000_000, "DEFAULT_CEILING_SCALAR_CELLS": 4_000_000_000, + "DEFAULT_POLARS_ROUTE_ROWS": 8_000_000} + +TIER_RANK = {"schema": 0, "scalar": 1, "full": 2} + + +@pytest.fixture +def sp(): + spec = importlib.util.find_spec("buckaroo.server.stats_policy") + assert spec is not None, "buckaroo.server.stats_policy is not importable" + return importlib.import_module("buckaroo.server.stats_policy") + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch): + """A developer's own BUCKAROO_STATS_* overrides must not leak in.""" + for name in ENV_FIELDS: + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def lim(sp): + """The module defaults. The tables are written against the calibrated + values, so moving a ``DEFAULT_*`` constant fails them.""" + return sp.StatsLimits() + + +def _resolve(sp, lim, backend, source_kind, rows, cols, host_tier=None, bytes=None): + return sp.resolve_stats_policy( + backend, source_kind, rows, cols, bytes=bytes, host_tier=host_tier, limits=lim) + + +# (backend, source_kind, rows, cols, host_tier) -> (tier_target, auto_request, +# requestable, reason). The row counts of 42.3M, 54.1M and 78.0M and the 44 +# columns are the three tallyman entries over 70 s; 10.8M x 43 is parking_2017. +# Each threshold has an equal, a one-below and a one-above row, in rows and, +# where the rule reads cells, in cells (a row count or a column count moves +# the product). The comments give the cell count where it decides the row. +RESOLVE_TABLE = [ + # Eager backends resolve to full and ignore the host tier and the cell bounds. + ("pandas", "memory", 52_814, 27, None, ("full", True, [], None)), + ("pandas", "parquet", 1_000_000, 43, None, ("full", True, [], None)), + ("polars", "parquet", 10_800_000, 43, None, ("full", True, [], None)), + ("polars", "parquet", 10_800_000, 43, "schema", ("full", True, [], None)), + ("pandas", "csv", 52_814, 27, "scalar", ("full", True, [], None)), + ("pandas", "memory", 30_000_000, 100, None, ("full", True, [], None)), + # xorq sized by the policy alone: full up to 12M rows. + ("xorq", "parquet", 0, 0, None, ("full", True, [], None)), + ("xorq", "parquet", 52_814, 27, None, ("full", True, [], None)), + ("xorq", "parquet", 10_800_000, 43, None, ("full", True, [], None)), + ("xorq", "parquet", 11_999_999, 43, None, ("full", True, [], None)), + ("xorq", "parquet", 12_000_000, 43, None, ("full", True, [], None)), + ("xorq", "parquet", 12_000_001, 43, None, ("scalar", True, ["full"], "size")), + ("xorq", "parquet", 12_000_000, 10, None, ("full", True, [], None)), + ("xorq", "parquet", 12_000_001, 10, None, ("scalar", True, ["full"], "size")), + # The 11.7M-row CSV entries: 515.8M cells at 44 columns, 527.5M at 45. + ("xorq", "csv", 11_721_603, 44, None, ("full", True, [], None)), + ("xorq", "csv", 11_721_603, 45, None, ("scalar", True, ["full"], "size")), + # full auto also stops at 520M cells, reached by rows or by columns. + ("xorq", "parquet", 9_999_999, 52, None, ("full", True, [], None)), + ("xorq", "parquet", 10_000_000, 52, None, ("full", True, [], None)), + ("xorq", "parquet", 10_000_001, 52, None, ("scalar", True, ["full"], "size")), + ("xorq", "parquet", 8_000_000, 65, None, ("full", True, [], None)), + ("xorq", "parquet", 8_000_000, 66, None, ("scalar", True, ["full"], "size")), + # scalar auto up to 1.0B cells; full stays requestable up to its own ceiling. + ("xorq", "parquet", 11_000_000, 90, None, ("scalar", True, ["full"], "size")), + ("xorq", "parquet", 11_000_000, 100, None, ("schema", False, ["scalar"], "size")), + ("xorq", "parquet", 19_999_999, 50, None, ("scalar", True, ["full"], "size")), + ("xorq", "parquet", 20_000_000, 50, None, ("scalar", True, ["full"], "size")), + ("xorq", "parquet", 20_000_001, 50, None, ("schema", False, ["scalar"], "size")), + ("xorq", "parquet", 22_727_272, 44, None, ("scalar", True, ["full"], "size")), + ("xorq", "parquet", 22_727_273, 44, None, ("schema", False, ["scalar"], "size")), + ("xorq", "parquet", 12_500_000, 80, None, ("scalar", True, ["full"], "size")), + ("xorq", "parquet", 12_500_000, 81, None, ("schema", False, ["scalar"], "size")), + # The three slow tallyman entries are above 1.8B cells and start at schema. + ("xorq", "csv", 42_300_000, 44, None, ("schema", False, ["scalar"], "size")), + ("xorq", "csv", 50_000_000, 44, None, ("schema", False, ["scalar"], "size")), + ("xorq", "csv", 54_100_000, 44, None, ("schema", False, ["scalar"], "size")), + ("xorq", "csv", 78_000_000, 44, None, ("schema", False, ["scalar"], "size")), + ("xorq", "csv", 78_000_000, 44, "auto", ("schema", False, ["scalar"], "size")), + # Past the scalar ceiling (4.0B cells) nothing is requestable. + ("xorq", "csv", 100_000_000, 40, None, ("schema", False, ["scalar"], "size")), + ("xorq", "csv", 100_000_001, 40, None, ("schema", False, [], "size")), + # The host lowers freely. + ("xorq", "parquet", 1_000_000, 43, "schema", ("schema", False, ["scalar", "full"], "host")), + ("xorq", "parquet", 1_000_000, 43, "scalar", ("scalar", True, ["full"], "host")), + ("xorq", "parquet", 1_000_000, 43, "full", ("full", True, [], None)), + # The host raises only as far as the ceiling on full: 25M rows ... + ("xorq", "parquet", 12_000_000, 43, "full", ("full", True, [], None)), + ("xorq", "parquet", 24_999_999, 10, "full", ("full", True, [], None)), + ("xorq", "parquet", 25_000_000, 10, "full", ("full", True, [], None)), + ("xorq", "parquet", 25_000_001, 10, "full", ("scalar", True, [], "ceiling")), + ("xorq", "parquet", 25_000_000, 10, None, ("scalar", True, ["full"], "size")), + ("xorq", "parquet", 25_000_001, 10, None, ("scalar", True, [], "size")), + # ... or 1.0B cells, reached by rows or by columns. + ("xorq", "parquet", 24_999_999, 40, "full", ("full", True, [], None)), + ("xorq", "parquet", 25_000_000, 40, "full", ("full", True, [], None)), + ("xorq", "parquet", 25_000_000, 41, "full", ("scalar", True, [], "ceiling")), + ("xorq", "parquet", 20_000_000, 50, "full", ("full", True, [], None)), + ("xorq", "parquet", 20_000_001, 50, "full", ("scalar", True, [], "ceiling")), + ("xorq", "parquet", 11_000_000, 90, "full", ("full", True, [], None)), + ("xorq", "parquet", 11_000_000, 91, "full", ("scalar", True, [], "ceiling")), + ("xorq", "csv", 42_300_000, 44, "full", ("scalar", True, [], "ceiling")), + ("xorq", "csv", 50_000_000, 44, "full", ("scalar", True, [], "ceiling")), + ("xorq", "csv", 50_000_001, 44, "full", ("scalar", True, [], "ceiling")), + ("xorq", "csv", 78_000_000, 44, "full", ("scalar", True, [], "ceiling")), + ("xorq", "csv", 78_000_000, 44, "scalar", ("scalar", True, [], "host")), + ("xorq", "csv", 78_000_000, 44, "schema", ("schema", False, ["scalar"], "host")), + # The ceiling on scalar is 4.0B cells, reached by rows or by columns. + ("xorq", "csv", 99_999_999, 40, "scalar", ("scalar", True, [], "host")), + ("xorq", "csv", 100_000_000, 40, "scalar", ("scalar", True, [], "host")), + ("xorq", "csv", 100_000_001, 40, "scalar", ("schema", False, [], "ceiling")), + ("xorq", "csv", 50_000_000, 80, "scalar", ("scalar", True, [], "host")), + ("xorq", "csv", 50_000_000, 81, "scalar", ("schema", False, [], "ceiling")), + ("xorq", "csv", 100_000_000, 40, "full", ("scalar", True, [], "ceiling")), + ("xorq", "csv", 100_000_001, 40, "full", ("schema", False, [], "ceiling")), + ("xorq", "csv", 100_000_001, 40, "schema", ("schema", False, [], "host")), +] + + +def _table_id(case): + backend, _kind, rows, cols, host_tier, _expected = case + return f"{backend}-{rows}x{cols}-{host_tier}" + + +class TestResolveStatsPolicy: + @pytest.mark.parametrize( + "backend, source_kind, rows, cols, host_tier, expected", RESOLVE_TABLE, + ids=[_table_id(c) for c in RESOLVE_TABLE]) + def test_table(self, sp, lim, backend, source_kind, rows, cols, host_tier, expected): + out = _resolve(sp, lim, backend, source_kind, rows, cols, host_tier) + got = (out["tier_target"], out["auto_request"], out["requestable"], out["reason"]) + assert got == expected + + def test_result_has_exactly_the_planned_keys(self, sp, lim): + out = _resolve(sp, lim, "xorq", "parquet", 12_400_000, 43) + assert set(out) == {"tier_target", "auto_request", "requestable", "reason", "estimate"} + + def test_result_is_json_serialisable(self, sp, lim): + """It becomes df_meta.stats, so it has to survive json.dumps.""" + out = _resolve(sp, lim, "xorq", "csv", 78_000_000, 44, "full") + assert json.loads(json.dumps(out)) == out + + def test_estimate_reports_the_inputs(self, sp, lim): + out = _resolve(sp, lim, "xorq", "parquet", 12_400_000, 43) + assert out["estimate"] == {"rows": 12_400_000, "cols": 43} + + def test_estimate_includes_bytes_when_given(self, sp, lim): + out = _resolve(sp, lim, "xorq", "parquet", 12_400_000, 43, bytes=987_654_321) + assert out["estimate"] == {"rows": 12_400_000, "cols": 43, "bytes": 987_654_321} + + def test_numpy_integer_counts_are_accepted(self, sp, lim): + out = _resolve(sp, lim, "xorq", "parquet", np.int64(13_000_000), np.int64(43)) + assert out["tier_target"] == "scalar" + assert out["estimate"] == {"rows": 13_000_000, "cols": 43} + + @pytest.mark.parametrize("rows, cols", [(-1, 3), (3, -1), (None, 3), (3, None), (1.5, 3)]) + def test_bad_counts_are_rejected(self, sp, lim, rows, cols): + with pytest.raises((TypeError, ValueError)): + _resolve(sp, lim, "xorq", "parquet", rows, cols) + + def test_numpy_integer_bytes_are_echoed_as_plain_ints(self, sp, lim): + """df.memory_usage(deep=True).sum() is a np.int64, and the result has + to survive json.dumps.""" + out = _resolve(sp, lim, "xorq", "parquet", 12_400_000, 43, bytes=np.int64(987_654_321)) + assert type(out["estimate"]["bytes"]) is int + assert json.loads(json.dumps(out)) == out + + @pytest.mark.parametrize("bad", [-1, "lots", 1.5, object()]) + def test_bad_bytes_are_rejected(self, sp, lim, bad): + with pytest.raises((TypeError, ValueError)): + _resolve(sp, lim, "xorq", "parquet", 12_400_000, 43, bytes=bad) + + def test_a_host_tier_in_the_bytes_slot_is_rejected(self, sp): + """bytes sits before host_tier, so a positional slip must not be taken + for a byte count.""" + with pytest.raises((TypeError, ValueError)): + sp.resolve_stats_policy("xorq", "csv", 12_000_000, 43, "full") + + @pytest.mark.parametrize("bad", [None, 5, b"csv"]) + def test_bad_source_kind_is_rejected(self, sp, lim, bad): + with pytest.raises(TypeError): + _resolve(sp, lim, "xorq", bad, 1_000, 3) + + def test_unknown_host_tier_is_rejected(self, sp, lim): + with pytest.raises(ValueError): + _resolve(sp, lim, "xorq", "parquet", 1_000, 3, "everything") + + def test_unknown_host_tier_is_rejected_for_eager_backends_too(self, sp, lim): + with pytest.raises(ValueError): + _resolve(sp, lim, "pandas", "memory", 1_000, 3, "everything") + + def test_defaults_refuse_full_for_the_78m_row_entry(self, sp): + """No calibration may let a host request full on the 215 s entry.""" + out = sp.resolve_stats_policy("xorq", "csv", 78_000_000, 44, host_tier="full") + assert TIER_RANK[out["tier_target"]] < TIER_RANK["full"] + assert out["reason"] == "ceiling" + assert "full" not in out["requestable"] + + def test_defaults_let_the_control_recover_parking_2017(self, sp): + """10.8M x 43 columns took 3.7 s of stats and was fine, so a default + that withholds it must offer full on demand.""" + out = sp.resolve_stats_policy("xorq", "parquet", 10_800_000, 43) + assert out["tier_target"] == "full" or "full" in out["requestable"] + + +class TestCalibratedDefaults: + """The module defaults are the phase-0 proposals, one literal per value.""" + + def test_limits_defaults(self, sp): + assert dataclasses.asdict(sp.StatsLimits()) == CALIBRATED + + def test_module_constants(self, sp): + assert {name: getattr(sp, name, None) for name in CALIBRATED_CONSTANTS} == CALIBRATED_CONSTANTS + + def test_from_env_with_a_clean_environment_is_the_calibrated_set(self, sp): + assert dataclasses.asdict(sp.StatsLimits.from_env()) == CALIBRATED + + def test_every_limit_has_an_environment_override(self, sp): + """A new limit without an override could not be tuned on a server.""" + assert set(ENV_FIELDS.values()) == {f.name for f in dataclasses.fields(sp.StatsLimits)} + + +class TestCeiling: + """The ceiling is applied inside the function, so every caller gets it: + load, /reload_expr and a stats_request {force} all end in this call.""" + + HOST_TIERS = [None, "auto", "schema", "scalar", "full"] + + def test_host_full_above_the_ceiling_resolves_lower(self, sp, lim): + out = _resolve(sp, lim, "xorq", "csv", 78_000_000, 44, "full") + assert out["tier_target"] == "scalar" + assert out["reason"] == "ceiling" + + def test_reload_re_resolves_against_the_new_row_count(self, sp, lim): + """/reload_expr keeps the stored host tier and re-resolves. An entry + that fit under the ceiling and then grew past it loses full.""" + before = _resolve(sp, lim, "xorq", "parquet", 20_000_000, 44, "full") + assert before["tier_target"] == "full" + after = _resolve(sp, lim, "xorq", "parquet", 78_000_000, 44, "full") + assert after["tier_target"] == "scalar" + assert after["reason"] == "ceiling" + + def test_force_cannot_exceed_the_ceiling(self, sp, lim): + """A force request passes the requested tier as host_tier.""" + loaded = _resolve(sp, lim, "xorq", "csv", 78_000_000, 44) + assert loaded["tier_target"] == "schema" + assert "full" not in loaded["requestable"] + forced = _resolve(sp, lim, "xorq", "csv", 78_000_000, 44, "full") + assert TIER_RANK[forced["tier_target"]] < TIER_RANK["full"] + assert forced["reason"] == "ceiling" + + def test_ceiling_applies_to_the_automatic_choice_too(self, sp): + """A server whose ceiling sits below its own auto threshold is + clamped, with no host tier involved.""" + lim = sp.StatsLimits(full_auto_rows=10_000_000, ceiling_full_rows=5_000_000) + out = sp.resolve_stats_policy("xorq", "parquet", 7_000_000, 10, limits=lim) + assert out["tier_target"] == "scalar" + assert out["reason"] == "ceiling" + + def test_scalar_ceiling_can_leave_nothing_requestable(self, sp): + lim = sp.StatsLimits(ceiling_full_rows=50_000_000, ceiling_scalar_cells=1_000_000_000) + out = sp.resolve_stats_policy("xorq", "csv", 78_000_000, 44, host_tier="full", limits=lim) + assert out["tier_target"] == "schema" + assert out["reason"] == "ceiling" + assert out["auto_request"] is False + assert out["requestable"] == [] + + def test_scalar_ceiling_does_not_touch_smaller_entries(self, sp): + lim = sp.StatsLimits(ceiling_full_rows=50_000_000, ceiling_scalar_cells=1_000_000_000) + out = sp.resolve_stats_policy("xorq", "parquet", 1_000_000, 44, host_tier="full", limits=lim) + assert out["tier_target"] == "full" + + @pytest.mark.parametrize("rows", + [0, 1_000, 10_000_000, 11_000_000, 50_000_000, 50_000_001, 78_000_000, 500_000_000]) + @pytest.mark.parametrize("cols", [1, 44]) + @pytest.mark.parametrize("host_tier", HOST_TIERS) + def test_full_never_resolves_above_the_ceiling(self, sp, lim, rows, cols, host_tier): + out = _resolve(sp, lim, "xorq", "parquet", rows, cols, host_tier) + if rows > lim.ceiling_full_rows: + assert out["tier_target"] != "full" + assert "full" not in out["requestable"] + + @pytest.mark.parametrize("rows", [1_000, 10_800_000, 42_300_000, 50_000_001, 78_000_000]) + def test_requestable_is_exactly_what_a_force_would_grant(self, sp, lim, rows): + """Every tier the result offers is granted when asked for, and every + tier above the target that it does not offer is clipped.""" + out = _resolve(sp, lim, "xorq", "parquet", rows, 44) + for tier in ("scalar", "full"): + if TIER_RANK[tier] <= TIER_RANK[out["tier_target"]]: + continue + forced = _resolve(sp, lim, "xorq", "parquet", rows, 44, tier) + if tier in out["requestable"]: + assert forced["tier_target"] == tier + else: + assert forced["tier_target"] != tier + assert forced["reason"] == "ceiling" + + + @pytest.mark.parametrize("rows, cols", + [(20_000_001, 50), (25_000_000, 41), (11_000_000, 91), (24_999_999, 41), (42_300_000, 44), (78_000_000, 44)]) + @pytest.mark.parametrize("host_tier", HOST_TIERS) + def test_full_never_resolves_above_the_cell_ceiling(self, sp, lim, rows, cols, host_tier): + """1.0B cells, whatever the row count and the host asks for.""" + assert rows * cols > 1_000_000_000 + out = _resolve(sp, lim, "xorq", "parquet", rows, cols, host_tier) + assert out["tier_target"] != "full" + assert "full" not in out["requestable"] + + @pytest.mark.parametrize("rows, cols", + [(25_000_001, 10), (25_000_001, 1), (30_000_000, 5), (42_300_000, 4), (78_000_000, 1)]) + @pytest.mark.parametrize("host_tier", HOST_TIERS) + def test_full_never_resolves_above_25m_rows_however_few_the_cells(self, sp, lim, rows, cols, host_tier): + """The row ceiling holds on its own: these entries are under 1.0B cells.""" + assert rows * cols < 1_000_000_000 + out = _resolve(sp, lim, "xorq", "parquet", rows, cols, host_tier) + assert out["tier_target"] != "full" + assert "full" not in out["requestable"] + + @pytest.mark.parametrize("rows, cols", + [(100_000_001, 40), (50_000_000, 81), (1_000_000, 4_001), (500_000_000, 10)]) + @pytest.mark.parametrize("host_tier", HOST_TIERS) + def test_nothing_above_the_scalar_ceiling_resolves_past_schema(self, sp, lim, rows, cols, host_tier): + """4.0B cells, the placeholder scalar ceiling.""" + assert rows * cols > 4_000_000_000 + out = _resolve(sp, lim, "xorq", "parquet", rows, cols, host_tier) + assert out["tier_target"] == "schema" + assert out["requestable"] == [] + + def test_a_scalar_ceiling_is_on_by_default(self, sp): + out = sp.resolve_stats_policy("xorq", "csv", 100_000_001, 40, host_tier="scalar", limits=sp.StatsLimits()) + assert out["tier_target"] == "schema" + assert out["reason"] == "ceiling" + + + @pytest.mark.parametrize("rows, cols", + [(12_000_001, 43), (10_000_001, 52), (11_000_000, 91), (20_000_000, 50), (20_000_001, 50), (22_727_273, 44), + (25_000_000, 40), (25_000_000, 41), (25_000_001, 10), (50_000_000, 81), (100_000_000, 40), + (100_000_001, 40)]) + def test_requestable_matches_a_force_at_the_row_and_cell_boundaries(self, sp, lim, rows, cols): + """With the cell bounds, the tiers a result offers are still exactly + the ones a force request is granted.""" + out = _resolve(sp, lim, "xorq", "parquet", rows, cols) + for tier in ("scalar", "full"): + if TIER_RANK[tier] <= TIER_RANK[out["tier_target"]]: + continue + forced = _resolve(sp, lim, "xorq", "parquet", rows, cols, tier) + if tier in out["requestable"]: + assert forced["tier_target"] == tier + else: + assert forced["tier_target"] != tier + assert forced["reason"] == "ceiling" + + +class TestLimitsFromEnv: + def test_defaults_when_the_environment_is_empty(self, sp): + assert sp.StatsLimits.from_env() == sp.StatsLimits() + + @pytest.mark.parametrize("name, field", ENV_FIELDS.items()) + def test_each_variable_overrides_its_field(self, sp, monkeypatch, name, field): + monkeypatch.setenv(name, "12345") + assert getattr(sp.StatsLimits.from_env(), field) == 12345 + + def test_underscored_integers_are_accepted(self, sp, monkeypatch): + monkeypatch.setenv("BUCKAROO_STATS_FULL_AUTO_ROWS", "2_000_000") + assert sp.StatsLimits.from_env().full_auto_rows == 2_000_000 + + @pytest.mark.parametrize("name, field", ENV_FIELDS.items()) + @pytest.mark.parametrize("bad", ["lots", "-5", "1.5"]) + def test_invalid_value_falls_back_to_the_default_and_warns(self, sp, monkeypatch, caplog, name, field, bad): + monkeypatch.setenv(name, bad) + with caplog.at_level(logging.WARNING): + limits = sp.StatsLimits.from_env() + assert getattr(limits, field) == getattr(sp.StatsLimits(), field) + assert name in caplog.text + + def test_empty_value_means_unset(self, sp, monkeypatch): + monkeypatch.setenv("BUCKAROO_STATS_CEILING_SCALAR_CELLS", "") + assert sp.StatsLimits.from_env().ceiling_scalar_cells == sp.StatsLimits().ceiling_scalar_cells + + def test_resolve_reads_the_environment_when_no_limits_are_passed(self, sp, monkeypatch): + monkeypatch.setenv("BUCKAROO_STATS_CEILING_FULL_ROWS", "1000") + out = sp.resolve_stats_policy("xorq", "parquet", 5_000, 10, host_tier="full") + assert out["tier_target"] == "scalar" + assert out["reason"] == "ceiling" + + def test_the_full_auto_cell_bound_comes_from_the_environment(self, sp, monkeypatch): + args = ("xorq", "parquet", 5_000, 10) + assert sp.resolve_stats_policy(*args)["tier_target"] == "full" + monkeypatch.setenv("BUCKAROO_STATS_FULL_AUTO_CELLS", "1000") + out = sp.resolve_stats_policy(*args) + assert out["tier_target"] == "scalar" + assert out["reason"] == "size" + + def test_the_full_ceiling_cell_bound_comes_from_the_environment(self, sp, monkeypatch): + args = ("xorq", "parquet", 5_000, 10) + assert sp.resolve_stats_policy(*args, host_tier="full")["tier_target"] == "full" + monkeypatch.setenv("BUCKAROO_STATS_CEILING_FULL_CELLS", "1000") + out = sp.resolve_stats_policy(*args, host_tier="full") + assert out["tier_target"] == "scalar" + assert out["reason"] == "ceiling" + + def test_the_scalar_ceiling_comes_from_the_environment(self, sp, monkeypatch): + args = ("xorq", "parquet", 5_000, 10) + monkeypatch.setenv("BUCKAROO_STATS_CEILING_SCALAR_CELLS", "1000") + out = sp.resolve_stats_policy(*args, host_tier="scalar") + assert out["tier_target"] == "schema" + assert out["reason"] == "ceiling" + + def test_the_environment_is_read_on_each_call(self, sp, monkeypatch): + args = ("xorq", "parquet", 5_000, 10) + assert sp.resolve_stats_policy(*args, host_tier="full")["tier_target"] == "full" + monkeypatch.setenv("BUCKAROO_STATS_CEILING_FULL_ROWS", "1000") + assert sp.resolve_stats_policy(*args, host_tier="full")["tier_target"] == "scalar" + + +class _CountingFile(io.BytesIO): + """A binary file that counts the bytes read out of it.""" + + def __init__(self, data): + super().__init__(data) + self.bytes_read = 0 + + def read(self, size=-1): + out = super().read(size) + self.bytes_read += len(out) + return out + + def read1(self, size=-1): + out = super().read1(size) + self.bytes_read += len(out) + return out + + def readinto(self, b): + n = super().readinto(b) + self.bytes_read += n + return n + + +def _parquet_bytes(rows, row_group_size): + """Incompressible float columns, so the file is mostly row-group data.""" + rng = np.random.default_rng(0) + table = pa.table({f"c{i}": rng.random(rows) for i in range(4)}) + buf = io.BytesIO() + pq.write_table(table, buf, row_group_size=row_group_size, compression="none") + return buf.getvalue() + + +class TestProbes: + def test_parquet_rows_reads_the_footer_and_no_row_group(self, sp): + data = _parquet_bytes(200_000, 50_000) + source = _CountingFile(data) + assert sp.probe_parquet_rows(source) == 200_000 + assert len(data) > 5_000_000 + assert source.bytes_read * 20 < len(data) + + def test_parquet_rows_from_a_path_decodes_no_data(self, sp, tmp_path, monkeypatch): + path = tmp_path / "t.parquet" + path.write_bytes(_parquet_bytes(120_000, 40_000)) + + def boom(*args, **kwargs): + raise AssertionError("the footer probe decoded row-group data") + + monkeypatch.setattr(pq.ParquetFile, "read", boom) + monkeypatch.setattr(pq.ParquetFile, "read_row_group", boom) + monkeypatch.setattr(pq.ParquetFile, "iter_batches", boom) + monkeypatch.setattr(pq, "read_table", boom) + assert sp.probe_parquet_rows(str(path)) == 120_000 + assert sp.probe_parquet_rows(path) == 120_000 + + def test_parquet_rows_of_an_empty_file(self, sp): + buf = io.BytesIO() + pq.write_table(pa.table({"a": pa.array([], type=pa.int64())}), buf) + assert sp.probe_parquet_rows(io.BytesIO(buf.getvalue())) == 0 + + def test_dtypes_of_a_pandas_frame(self, sp): + df = pd.DataFrame({"a": [1, 2], "b": ["x", "y"]}) + assert sp.probe_dtypes(df) == {"a": "int64", "b": str(df["b"].dtype)} + + def test_dtypes_of_a_polars_frame(self, sp): + df = pl.DataFrame({"a": [1, 2], "b": ["x", "y"]}) + assert sp.probe_dtypes(df) == {"a": "Int64", "b": "String"} + + @pytest.mark.filterwarnings("error") + def test_dtypes_of_a_polars_scan_run_no_collect(self, sp, tmp_path, monkeypatch): + """Reading ``LazyFrame.dtypes`` resolves the plan and warns, so the + error filter also pins that the probe goes through ``collect_schema``.""" + path = tmp_path / "t.parquet" + pl.DataFrame({"a": [1, 2], "b": ["x", "y"]}).write_parquet(path) + lf = pl.scan_parquet(path) + + def boom(*args, **kwargs): + raise AssertionError("the dtype probe collected a LazyFrame") + + monkeypatch.setattr(pl.LazyFrame, "collect", boom) + assert sp.probe_dtypes(lf) == {"a": "Int64", "b": "String"} + + def test_dtypes_keep_column_order(self, sp): + df = pd.DataFrame({"z": [1], "a": [2], "m": [3]}) + assert list(sp.probe_dtypes(df)) == ["z", "a", "m"] + + def test_dtypes_of_something_without_a_schema_raise(self, sp): + with pytest.raises(TypeError): + sp.probe_dtypes(object()) + + def test_xorq_probes_and_a_known_count_execute_nothing(self, sp, monkeypatch): + """The count is an input: the caller passes the one load already took + (``_expr_count``) and the policy never runs one itself.""" + xo = pytest.importorskip("xorq.api") + from xorq.vendor.ibis.expr.types.core import Expr + + expr = xo.memtable({"a": [1, 2, 3], "b": ["x", "y", "z"]}, name="t") + + def boom(*args, **kwargs): + raise AssertionError("a probe or the policy executed a xorq query") + + for name in ("execute", "to_pyarrow", "to_pyarrow_batches", "to_pandas"): + if hasattr(Expr, name): + monkeypatch.setattr(Expr, name, boom) + + dtypes = sp.probe_dtypes(expr) + assert dtypes == {"a": "int64", "b": "string"} + out = sp.resolve_stats_policy("xorq", "expr", 78_000_000, len(dtypes), host_tier="full") + assert out["reason"] == "ceiling" + + +class TestRoutePolarsEntry: + def test_above_r_goes_to_xorq(self, sp, lim): + r = lim.polars_route_rows + assert sp.route_polars_entry(r + 1, 43, limits=lim) == "xorq" + assert sp.route_polars_entry(78_000_000, 44, limits=lim) == "xorq" + + def test_at_and_below_r_stay_eager(self, sp, lim): + r = lim.polars_route_rows + assert sp.route_polars_entry(r, 43, limits=lim) == "eager" + assert sp.route_polars_entry(52_814, 27, limits=lim) == "eager" + assert sp.route_polars_entry(0, 0, limits=lim) == "eager" + + def test_r_comes_from_the_limits_object(self, sp): + lim = sp.StatsLimits(polars_route_rows=1_000) + assert sp.route_polars_entry(1_001, 3, limits=lim) == "xorq" + assert sp.route_polars_entry(1_000, 3, limits=lim) == "eager" + + def test_r_comes_from_the_environment_by_default(self, sp, monkeypatch): + assert sp.route_polars_entry(5_000, 3) == "eager" + monkeypatch.setenv("BUCKAROO_POLARS_ROUTE_ROWS", "1000") + assert sp.route_polars_entry(5_000, 3) == "xorq" + + @pytest.mark.parametrize("rows, expected", + [(0, "eager"), (7_999_999, "eager"), (8_000_000, "eager"), (8_000_001, "xorq"), (10_000_000, "xorq"), + (10_800_000, "xorq")]) + def test_default_r_is_8m_rows(self, sp, rows, expected): + """Equal, one below and one above R, on the module defaults; 10.8M + rows (parking_2017) was eager under the old 10M threshold.""" + assert sp.route_polars_entry(rows, 43) == expected + + def test_defaults_route_the_78m_row_entry_to_xorq(self, sp): + assert sp.route_polars_entry(78_000_000, 44) == "xorq" + + def test_the_result_is_one_of_two_strings(self, sp, lim): + for rows in (0, 10_000_000, 10_000_001): + assert sp.route_polars_entry(rows, 43, limits=lim) in ("xorq", "eager") + + @pytest.mark.parametrize("rows, cols", [(-1, 3), (3, -1), (None, 3), (1.5, 3)]) + def test_bad_counts_are_rejected(self, sp, lim, rows, cols): + with pytest.raises((TypeError, ValueError)): + sp.route_polars_entry(rows, cols, limits=lim) + + +# --------------------------------------------------------------------------- +# The policy on the session and the wire (rows-first p33) +# --------------------------------------------------------------------------- + +# The table the wire tests load: five rows and three columns. +ESTIMATE = {"rows": 5, "cols": 3} +ONDEMAND_CAPS = frozenset({"stats_update", "stats_ondemand"}) +UPDATE_CAPS = frozenset({"stats_update"}) +LEGACY_CAPS = frozenset() + +# Limits that put a five-by-three table in each tier. +SCALAR_BY_SIZE = dict(full_auto_rows=3, scalar_auto_cells=1_000) +SCHEMA_BY_SIZE = dict(full_auto_rows=3, scalar_auto_cells=10) +FULL_OVER_CEILING = dict(ceiling_full_rows=3) +SCALAR_OVER_CEILING = dict(ceiling_scalar_cells=10) + + +def _session(sp, stats_tier="auto", delivery="deferred", limits=None, resolve=True): + """A buckaroo-mode session after a load that resolved its policy and began + its first stats generation, the way ``/load_expr`` leaves one.""" + session = session_mod.SessionState(session_id="s", path="p") + session.mode = "buckaroo" + session.df_meta = {"total_rows": 5} + session.stats_tier, session.stats_delivery = stats_tier, delivery + session.stats_policy = ( + sp.resolve_stats_policy("xorq", "xorq_build", 5, 3, host_tier=stats_tier, + limits=sp.StatsLimits(**(limits or {}))) + if resolve else None) + session_mod.begin_stats_generation(session) + return session + + +def _client(caps): + return SimpleNamespace(caps=caps, search_string="") + + +# (host tier, delivery, limits) -> what an ``stats_ondemand`` client is told. +# A field equal to its documented default is left out, and ``tier_target`` and +# ``estimate`` are always there once a policy is reported. +POLICY_FRAMES = [ + ("auto", "deferred", {}, {"status": "pending", "tier": "schema", "gen": 1, "tier_target": "full", + "requestable": [], "estimate": ESTIMATE}), + ("auto", "deferred", SCALAR_BY_SIZE, {"status": "not_computed", "tier": "schema", "gen": 1, "reason": "size", + "tier_target": "scalar", "estimate": ESTIMATE}), + ("auto", "deferred", SCHEMA_BY_SIZE, {"status": "not_computed", "tier": "schema", "gen": 1, "reason": "size", + "tier_target": "schema", "auto_request": False, "requestable": ["scalar", "full"], "estimate": ESTIMATE}), + ("full", "deferred", FULL_OVER_CEILING, {"status": "not_computed", "tier": "schema", "gen": 1, + "reason": "ceiling", "tier_target": "scalar", "requestable": [], "estimate": ESTIMATE}), + ("scalar", "deferred", {}, {"status": "not_computed", "tier": "schema", "gen": 1, "reason": "host", + "tier_target": "scalar", "estimate": ESTIMATE}), + ("schema", "deferred", {}, {"status": "not_computed", "tier": "schema", "gen": 1, "reason": "host", + "tier_target": "schema", "auto_request": False, "requestable": ["scalar", "full"], "estimate": ESTIMATE}), + ("scalar", "inline", SCALAR_OVER_CEILING, {"status": "not_computed", "tier": "schema", "gen": 1, + "reason": "ceiling", "tier_target": "schema", "auto_request": False, "requestable": [], "estimate": ESTIMATE}), + ("schema", "inline", {}, {"status": "not_computed", "tier": "schema", "gen": 1, "reason": "host", + "tier_target": "schema", "auto_request": False, "requestable": ["scalar", "full"], "estimate": ESTIMATE}), +] + + +class TestStatsFieldDefaults: + """What a client assumes for a ``df_meta.stats`` field the server leaves out.""" + + def test_a_frame_with_no_stats_is_complete(self): + """An old server sends no ``df_meta.stats``, and a new client reads it as complete.""" + assert session_mod.stats_with_defaults({"total_rows": 3}) == {"status": "complete", "tier": "full", + "tier_target": "full", "gen": None, "reason": None, "auto_request": True, "requestable": ["full"], + "estimate": None, "omitted_keys": [], "approx_keys": [], "demand_columns": []} + + def test_a_pending_frame_is_headed_for_full(self): + out = session_mod.stats_with_defaults({"stats": {"status": "pending", "tier": "schema", "gen": 3}}) + assert (out["status"], out["tier"], out["tier_target"], out["gen"]) == ("pending", "schema", "full", 3) + assert (out["auto_request"], out["requestable"]) == (True, ["full"]) + + def test_a_not_computed_frame_without_a_target_targets_the_tier_it_has(self): + out = session_mod.stats_with_defaults({"stats": {"status": "not_computed", "tier": "schema", "gen": 2, + "reason": "host"}}) + assert (out["tier_target"], out["reason"]) == ("schema", "host") + + def test_fields_the_frame_carries_win(self): + stats = {"status": "not_computed", "tier": "schema", "gen": 2, "reason": "size", "tier_target": "scalar", + "auto_request": False, "requestable": ["scalar", "full"], "estimate": ESTIMATE, "omitted_keys": ["a"], + "approx_keys": ["b"], "demand_columns": ["c"]} + out = session_mod.stats_with_defaults({"stats": stats}) + assert {key: out[key] for key in stats} == stats + + def test_the_defaults_are_not_shared_between_calls(self): + first = session_mod.stats_with_defaults({}) + first["requestable"].append("scalar") + first["omitted_keys"].append("x") + second = session_mod.stats_with_defaults({}) + assert (second["requestable"], second["omitted_keys"]) == (["full"], []) + + def test_the_documented_defaults(self): + assert session_mod.STATS_FIELD_DEFAULTS == {"auto_request": True, "requestable": ["full"], "omitted_keys": [], + "approx_keys": [], "demand_columns": []} + assert session_mod.STATS_TIER_REQUESTS == ("auto", "full", "scalar", "schema") + assert session_mod.STATS_REASONS == ("size", "host", "cost", "ceiling") + + +class TestInitialStatsStatus: + @pytest.mark.parametrize("stats_tier, delivery, policy, expected", [ + ("full", "inline", None, ("complete", None)), + ("full", "deferred", None, ("pending", None)), + ("schema", "inline", None, ("not_computed", "host")), + ("scalar", "deferred", None, ("not_computed", "host")), + # auto resolves against a schema-tier dataflow, which an inline session never has. + ("auto", "inline", None, ("complete", None)), + ("auto", "deferred", None, ("pending", None)), + ("auto", "deferred", {"tier_target": "full", "reason": None}, ("pending", None)), + ("auto", "deferred", {"tier_target": "scalar", "reason": "size"}, ("not_computed", "size")), + ("auto", "deferred", {"tier_target": "schema", "reason": "size"}, ("not_computed", "size")), + ("full", "deferred", {"tier_target": "scalar", "reason": "ceiling"}, ("not_computed", "ceiling")), + ("schema", "deferred", {"tier_target": "schema", "reason": "host"}, ("not_computed", "host")), + ("scalar", "inline", {"tier_target": "schema", "reason": "ceiling"}, ("not_computed", "ceiling"))]) + def test_the_status_follows_the_pair_and_the_policy(self, stats_tier, delivery, policy, expected): + assert session_mod.initial_stats_status(stats_tier, delivery, policy) == expected + + @pytest.mark.parametrize("stats_tier, delivery, expected", [ + ("full", "inline", "full"), ("auto", "inline", "full"), ("full", "deferred", "schema"), + ("auto", "deferred", "schema"), ("scalar", "inline", "schema"), ("schema", "inline", "schema"), + ("scalar", "deferred", "schema")]) + def test_the_dataflow_is_built_at_the_schema_tier_unless_stats_run_inline(self, stats_tier, delivery, expected): + """No scalar-tier units exist yet, so a scalar target builds at the schema tier.""" + assert session_mod.dataflow_stats_tier(stats_tier, delivery) == expected + + +class TestStatsMeta: + @pytest.mark.parametrize("stats_tier, delivery, limits, expected", POLICY_FRAMES) + def test_a_stats_ondemand_client_is_told_the_policy(self, sp, stats_tier, delivery, limits, expected): + session = _session(sp, stats_tier, delivery, limits) + assert session_mod.stats_meta(session) == expected + message = session_mod.build_state_message(session) + assert message["df_meta"] == {"total_rows": 5, "stats": expected} + + @pytest.mark.parametrize("stats_tier, limits", [("auto", SCALAR_BY_SIZE), ("auto", SCHEMA_BY_SIZE), + ("full", FULL_OVER_CEILING)]) + def test_a_client_without_stats_ondemand_is_told_the_stats_are_on_their_way(self, sp, stats_tier, limits): + """A target the server chose below full is applied only for a client that + can take it; the others are served as a deferred session headed for full.""" + session = _session(sp, stats_tier, "deferred", limits) + pending = {"status": "pending", "tier": "schema", "gen": 1} + assert session_mod.stats_meta(session, ondemand=False) == pending + assert session_mod.build_state_message(session, ondemand=False)["df_meta"]["stats"] == pending + + @pytest.mark.parametrize("stats_tier, delivery, limits, reason", [ + ("scalar", "deferred", {}, "host"), ("schema", "deferred", {}, "host"), ("schema", "inline", {}, "host"), + ("scalar", "inline", SCALAR_OVER_CEILING, "ceiling")]) + def test_a_tier_the_host_named_reaches_every_client(self, sp, stats_tier, delivery, limits, reason): + """The host chose it, so there is nothing for an older client to complete, + and it gets the status without the policy fields.""" + session = _session(sp, stats_tier, delivery, limits) + assert session_mod.stats_meta(session, ondemand=False) == {"status": "not_computed", "tier": "schema", + "gen": 1, "reason": reason} + + @pytest.mark.parametrize("ondemand", [True, False]) + def test_an_explicit_full_within_the_ceiling_reports_no_policy(self, sp, ondemand): + """The message is the one sent before the policy existed, though the + policy is resolved and recorded.""" + session = _session(sp, "full", "deferred") + assert session.stats_policy["tier_target"] == "full" + assert session_mod.stats_meta(session, ondemand=ondemand) == {"status": "pending", "tier": "schema", "gen": 1} + inline = _session(sp, "full", "inline", resolve=False) + assert session_mod.stats_meta(inline, ondemand=ondemand) is None + assert session_mod.build_state_message(inline, ondemand=ondemand)["df_meta"] == {"total_rows": 5} + + @pytest.mark.parametrize("stats_tier, limits", + [("auto", {}), ("auto", SCALAR_BY_SIZE), ("full", FULL_OVER_CEILING)]) + def test_a_completed_session_reports_no_policy(self, sp, stats_tier, limits): + session = _session(sp, stats_tier, "deferred", limits) + session.stats_status, session.stats_reason = "complete", None + assert session_mod.stats_meta(session) == {"status": "complete", "tier": "full", "gen": 1} + + def test_the_gen_follows_the_generation_and_the_status_restarts_from_the_policy(self, sp): + session = _session(sp, "auto", "deferred", SCALAR_BY_SIZE) + session.stats_status, session.stats_reason = "complete", None + session_mod.begin_stats_generation(session) + assert session_mod.stats_meta(session) == {"status": "not_computed", "tier": "schema", "gen": 2, + "reason": "size", "tier_target": "scalar", "estimate": ESTIMATE} + + def test_omitted_approx_and_demand_keys_ride_along_when_there_are_some(self, sp): + session = _session(sp, "auto", "deferred", SCALAR_BY_SIZE) + assert not {"omitted_keys", "approx_keys", "demand_columns"} & set(session_mod.stats_meta(session)) + session.stats_policy.update(omitted_keys=["value_counts"], approx_keys=["distinct_count"], + demand_columns=["a", "b"]) + stats = session_mod.stats_meta(session) + assert (stats["omitted_keys"], stats["approx_keys"], stats["demand_columns"]) == ( + ["value_counts"], ["distinct_count"], ["a", "b"]) + # A client reads what is left out as empty. + session.stats_policy.update(omitted_keys=[], approx_keys=[], demand_columns=[]) + assert not {"omitted_keys", "approx_keys", "demand_columns"} & set(session_mod.stats_meta(session)) + + def test_every_reason_the_server_sends_is_a_documented_one(self): + for *_, expected in POLICY_FRAMES: + assert expected.get("reason", "size") in session_mod.STATS_REASONS + + +class TestPolicyForTheSession: + """``resolve_session_policy``: what a load handler resolves, and when.""" + + @pytest.mark.parametrize("stats_tier", ["auto", "full", "scalar", "schema"]) + def test_a_schema_dataflow_resolves_against_the_count_it_has(self, stats_tier): + out = stats_wire.resolve_session_policy(stats_tier, "schema", 5, 3) + assert out["estimate"] == ESTIMATE + assert out["tier_target"] == {"auto": "full", "full": "full", "scalar": "scalar", "schema": "schema"}[stats_tier] + + def test_a_dataflow_that_ran_its_stats_has_nothing_to_resolve(self): + assert stats_wire.resolve_session_policy("full", "full", 5, 3) is None + assert stats_wire.resolve_session_policy("auto", "full", 5_000_000_000, 40) is None + + def test_the_ceiling_clips_a_host_that_asks_for_full(self, sp): + out = stats_wire.resolve_session_policy("full", "schema", 78_000_000, 44) + assert (out["tier_target"], out["reason"]) == ("scalar", "ceiling") + + def test_the_limits_are_read_on_each_call(self, monkeypatch): + assert stats_wire.resolve_session_policy("auto", "schema", 5, 3)["tier_target"] == "full" + monkeypatch.setenv("BUCKAROO_STATS_FULL_AUTO_ROWS", "3") + assert stats_wire.resolve_session_policy("auto", "schema", 5, 3)["tier_target"] == "scalar" + + +class TestCapabilities: + def test_the_second_bit_is_named(self): + assert stats_wire.STATS_UPDATE_CAP == "stats_update" + assert stats_wire.STATS_ONDEMAND_CAP == "stats_ondemand" + assert stats_wire.parse_caps("stats_update,stats_ondemand") == ONDEMAND_CAPS + + @pytest.mark.parametrize("caps, expected", [ + (ONDEMAND_CAPS, True), (UPDATE_CAPS, False), (LEGACY_CAPS, False), + # The bit means the client also merges stats_update; alone it is not enough. + (frozenset({"stats_ondemand"}), False)]) + def test_only_stats_update_with_stats_ondemand_counts_as_ondemand(self, caps, expected): + assert stats_wire.client_has_ondemand(_client(caps)) is expected + + def test_a_client_with_no_caps_attribute_is_not_ondemand(self): + assert stats_wire.client_has_ondemand(SimpleNamespace()) is False + + @pytest.mark.parametrize("caps, expected", [(ONDEMAND_CAPS, False), (UPDATE_CAPS, True), (LEGACY_CAPS, True)]) + def test_a_policy_target_below_full_is_missing_stats_to_every_client_but_an_ondemand_one(self, sp, caps, expected): + session = _session(sp, "auto", "deferred", SCHEMA_BY_SIZE) + assert stats_wire.stats_to_pull(session, _client(caps)) is expected + + @pytest.mark.parametrize("caps", [ONDEMAND_CAPS, UPDATE_CAPS, LEGACY_CAPS]) + def test_a_pending_session_has_stats_to_pull_for_everyone(self, sp, caps): + assert stats_wire.stats_to_pull(_session(sp, "auto", "deferred"), _client(caps)) is True + + @pytest.mark.parametrize("caps", [ONDEMAND_CAPS, UPDATE_CAPS, LEGACY_CAPS]) + def test_a_tier_the_host_named_has_nothing_to_pull(self, sp, caps): + assert stats_wire.stats_to_pull(_session(sp, "schema", "deferred"), _client(caps)) is False + assert stats_wire.stats_to_pull(_session(sp, "scalar", "deferred"), _client(caps)) is False + + +class TestScalarTierServing: + """``serves_scalar_tier`` (rows-first p34): which client a ``stats_request`` + is answered with the scalar tier for. A session whose policy target is + ``scalar`` and has nothing computed serves it to a client that advertised + ``stats_ondemand``, which takes tiers. Every other client is served as + before.""" + + @pytest.mark.parametrize("tier, limits, expected", [ + ("auto", SCALAR_BY_SIZE, True), + ("scalar", {}, True), + ("full", FULL_OVER_CEILING, True), + ("auto", SCHEMA_BY_SIZE, False), + ("schema", {}, False), + ("scalar", SCALAR_OVER_CEILING, False), + ("auto", {}, False), + ("full", {}, False)]) + def test_an_ondemand_client_is_served_a_session_whose_target_is_scalar(self, sp, tier, limits, expected): + session = _session(sp, tier, "deferred", limits) + assert stats_wire.serves_scalar_tier(session, _client(ONDEMAND_CAPS)) is expected + + @pytest.mark.parametrize("caps", [UPDATE_CAPS, LEGACY_CAPS, frozenset({"stats_ondemand"})]) + @pytest.mark.parametrize("tier, limits", [("auto", SCALAR_BY_SIZE), ("scalar", {})]) + def test_a_client_without_both_bits_is_not(self, sp, caps, tier, limits): + assert stats_wire.serves_scalar_tier(_session(sp, tier, "deferred", limits), _client(caps)) is False + + def test_a_session_whose_stats_are_complete_serves_no_tier(self, sp): + session = _session(sp, "auto", "deferred", SCALAR_BY_SIZE) + session.stats_status, session.stats_reason = "complete", None + assert stats_wire.serves_scalar_tier(session, _client(ONDEMAND_CAPS)) is False + + def test_a_session_with_no_policy_has_no_target_to_serve(self, sp): + session = _session(sp, "scalar", "inline", resolve=False) + assert (session.stats_status, session.stats_policy) == ("not_computed", None) + assert stats_wire.serves_scalar_tier(session, _client(ONDEMAND_CAPS)) is False + + +# --------------------------------------------------------------------------- +# Limits, override, cost guard and the demand scan (rows-first p36a) +# --------------------------------------------------------------------------- + +# The frame a request resolves a client's column names against: price, qty and +# category are the columns a client calls a, b and c. +FRAME = pd.DataFrame({"price": [12.5, 18.9, 7.4, 22.1, 1e9], "qty": [1, 2, 1, 3, 2], "category": list("abaca")}) +PAIRS = [("price", "a"), ("qty", "b"), ("category", "c")] +SEARCH_OP = [sQ("search"), lisp_sym("df"), "col", "a"] +CLEANING_OP = [lisp_sym("dropcol"), lisp_sym("df"), "col"] + + +def _with_frame(session, operations=()): + """Give ``session`` the dataflow a request is answered against, as far as a + refusal or a state test needs it: a frame to read column names from and the + operations that say whether it is filtered. No unit can run on it.""" + session.backend = "xorq" + session.xorq_dataflow = SimpleNamespace(operations=list(operations), processed_df=FRAME) + return session + + +def _limited(sp, monkeypatch, stats_tier="auto", limits=None, operations=()): + """A deferred session that resolved ``limits``, with the same thresholds in + the environment, where a request reads them again for its own check.""" + for name, value in (limits or {}).items(): + monkeypatch.setenv(f"BUCKAROO_STATS_{name.upper()}", str(value)) + return _with_frame(_session(sp, stats_tier, "deferred", limits), operations) + + +def _request(session, client, **fields): + return stats_wire.handle_stats_request( + session, {"type": "stats_request", "stats_gen": session.stats_gen, "scope": "raw", **fields}, client) + + +def _refusal(session, tier, reason): + return {"stats_gen": session.stats_gen, "scope": "raw", "tier": tier, "final": True, "remaining": 0, + "status": "not_computed", "reason": reason} + + +def _refusal_fields(reply): + return {key: reply[key] for key in ("stats_gen", "scope", "tier", "final", "remaining", "status", "reason")} + + +# (host tier, limits, the request fields, the tier the reply says was asked for). +# The five-by-three table is 15 cells: a ceiling_full_rows of 3 refuses full for +# it, and a ceiling_scalar_cells of 10 refuses scalar for the whole table. +OVER_THE_CEILING = [ + ("auto", {**SCHEMA_BY_SIZE, "ceiling_full_rows": 3}, {"tier": "full", "force": True}, "full"), + ("auto", {**SCHEMA_BY_SIZE, "ceiling_full_rows": 3}, {"tier": "full"}, "full"), + ("full", FULL_OVER_CEILING, {"tier": "full", "force": True}, "full"), + ("schema", SCALAR_OVER_CEILING, {"tier": "scalar", "force": True}, "scalar"), + ("schema", SCALAR_OVER_CEILING, {"tier": "full", "force": True}, "full"), + # Named columns are checked at their own cell count: all three are 15 cells. + ("scalar", SCALAR_OVER_CEILING, {"tier": "scalar", "columns": ["a", "b", "c"]}, "scalar")] + + +class TestRequestsOverTheCeiling: + """A request for a tier the ceiling refuses is answered ``stats_update + {final: true, status: "not_computed", reason: "ceiling"}`` and runs nothing, + whoever asks and whether or not it is forced.""" + + @pytest.mark.parametrize("stats_tier, limits, fields, tier", OVER_THE_CEILING) + def test_a_request_over_the_ceiling_is_refused_and_runs_nothing(self, sp, monkeypatch, stats_tier, limits, fields, + tier): + session = _limited(sp, monkeypatch, stats_tier, limits) + held = (session.stats_status, session.stats_reason, dict(session.stat_runs)) + reply = _request(session, _client(ONDEMAND_CAPS), incremental=True, **fields) + assert reply["type"] == "stats_update", reply + assert _refusal_fields(reply) == _refusal(session, tier, "ceiling") + assert "payload" not in reply and "df_display_args" not in reply + assert isinstance(reply["elapsed_ms"], (int, float)) and reply["elapsed_ms"] >= 0 + assert (session.stats_status, session.stats_reason, session.stat_runs) == held + assert session.stats_override is None + + def test_the_refusal_does_not_depend_on_the_request_being_incremental(self, sp, monkeypatch): + session = _limited(sp, monkeypatch, "full", FULL_OVER_CEILING) + reply = _request(session, _client(ONDEMAND_CAPS), tier="full", force=True) + assert _refusal_fields(reply) == _refusal(session, "full", "ceiling") + + +class TestMalformedTierRequests: + @pytest.mark.parametrize("fields", [ + {"tier": "schema"}, {"tier": "medium"}, {"tier": 3}, {"tier": ["full"]}, {"force": "yes"}, {"force": 1}, + {"tier": "scalar", "columns": "a"}, {"tier": "scalar", "columns": ["zz"]}, + {"tier": "scalar", "columns": [1, None]}, {"tier": "scalar", "columns": []}, + {"tier": "scalar", "columns": {"a": 1}}]) + def test_a_field_the_server_cannot_read_is_a_bad_request(self, sp, monkeypatch, fields): + session = _limited(sp, monkeypatch, "auto", SCHEMA_BY_SIZE) + reply = _request(session, _client(ONDEMAND_CAPS), **fields) + assert (reply["type"], reply["reason"], reply["current_gen"]) == ("stats_aborted", "bad_request", + session.stats_gen) + assert session.stat_runs == {} and session.stats_status == "not_computed" + + +class TestRequestableTiers: + """``requestable`` and the target are enforced by the server: a tier outside + them is not run, whatever the client believes.""" + + @pytest.mark.parametrize("stats_tier, limits, fields", [ + ("auto", {}, {"tier": "scalar"}), + ("auto", {}, {"tier": "scalar", "force": True}), + ("auto", SCALAR_BY_SIZE, {"tier": "full"}), + ("auto", SCHEMA_BY_SIZE, {"tier": "scalar"}), + ("auto", SCHEMA_BY_SIZE, {"tier": "full"}), + ("auto", SCHEMA_BY_SIZE, {}), + ("auto", SCHEMA_BY_SIZE, {"force": False})]) + def test_a_tier_that_is_not_the_target_or_above_it_with_force_is_not_requestable(self, sp, monkeypatch, stats_tier, + limits, fields): + """Below the target, or above it without ``force``, or a schema target that + nothing is requested for on its own.""" + session = _limited(sp, monkeypatch, stats_tier, limits) + reply = _request(session, _client(ONDEMAND_CAPS), incremental=True, **fields) + assert (reply["type"], reply["reason"]) == ("stats_aborted", "not_requestable"), reply + assert session.stat_runs == {} and session.stats_override is None + + +class TestCostPausedStatus: + @pytest.mark.parametrize("stats_tier, delivery, policy, expected", [ + ("auto", "deferred", {"tier_target": "full", "reason": None}, ("not_computed", "cost")), + ("auto", "deferred", {"tier_target": "scalar", "reason": "size"}, ("not_computed", "cost")), + ("auto", "deferred", {"tier_target": "schema", "reason": "size"}, ("not_computed", "cost")), + ("schema", "deferred", {"tier_target": "schema", "reason": "host"}, ("not_computed", "cost")), + # No policy, no pause: a dataflow built with its stats inline has no run to hold. + ("full", "inline", None, ("complete", None)), + ("schema", "deferred", None, ("not_computed", "host"))]) + def test_a_paused_session_starts_its_generation_not_computed_for_cost(self, stats_tier, delivery, policy, expected): + assert session_mod.initial_stats_status(stats_tier, delivery, policy, cost_paused=True) == expected + + def test_an_unpaused_session_is_as_before(self): + assert session_mod.initial_stats_status("auto", "deferred", {"tier_target": "full", "reason": None}, + cost_paused=False) == ("pending", None) + assert session_mod.initial_stats_status("auto", "deferred", {"tier_target": "scalar", "reason": "size"}) == ("not_computed", "size") + + def test_a_policy_target_of_full_is_pending_whatever_the_delivery(self): + """A policy exists only for a dataflow built at the schema tier, so a full + target has its stats still to run, as a forced request makes it for a + session the host named below full.""" + assert session_mod.initial_stats_status("schema", "inline", {"tier_target": "full", "reason": None}) == ("pending", None) + + @pytest.mark.parametrize("stats_tier, limits, expected", [ + ("auto", {}, {"status": "not_computed", "tier": "schema", "gen": 2, "reason": "cost", "tier_target": "full", + "requestable": [], "estimate": ESTIMATE}), + ("auto", SCALAR_BY_SIZE, {"status": "not_computed", "tier": "schema", "gen": 2, "reason": "cost", + "tier_target": "scalar", "estimate": ESTIMATE})]) + def test_the_pause_survives_a_new_generation_and_is_reported(self, sp, stats_tier, limits, expected): + session = _session(sp, stats_tier, "deferred", limits) + session.cost_paused = True + session_mod.begin_stats_generation(session) + assert session_mod.stats_meta(session) == expected + assert (session.stats_status, session.stats_reason) == ("not_computed", "cost") + + def test_clearing_the_pause_restores_the_status_the_policy_gives(self, sp): + session = _session(sp, "auto", "deferred", SCALAR_BY_SIZE) + session.cost_paused = True + session_mod.begin_stats_generation(session) + assert (session.stats_status, session.stats_reason) == ("not_computed", "cost") + session.cost_paused = False + session_mod.begin_stats_generation(session) + assert (session.stats_status, session.stats_reason) == ("not_computed", "size") + + @pytest.mark.parametrize("stats_tier, limits, tier", [("auto", {}, "full"), ("auto", SCALAR_BY_SIZE, "scalar"), + ("scalar", {}, "scalar")]) + def test_a_paused_session_refuses_a_request_that_is_not_forced(self, sp, monkeypatch, stats_tier, limits, tier): + session = _limited(sp, monkeypatch, stats_tier, limits) + session.cost_paused = True + session_mod.begin_stats_generation(session) + reply = _request(session, _client(ONDEMAND_CAPS), incremental=True) + assert reply["type"] == "stats_update", reply + assert _refusal_fields(reply) == _refusal(session, tier, "cost") + assert "payload" not in reply and isinstance(reply["elapsed_ms"], (int, float)) + assert session.stat_runs == {} + + def test_a_paused_session_refuses_a_named_columns_request_too(self, sp, monkeypatch): + """A demand request is automatic, so it is held by the pause like any other.""" + session = _limited(sp, monkeypatch, "auto", SCHEMA_BY_SIZE) + session.cost_paused = True + session_mod.begin_stats_generation(session) + reply = _request(session, _client(ONDEMAND_CAPS), tier="scalar", columns=["a"]) + assert _refusal_fields(reply) == _refusal(session, "scalar", "cost") + assert session.stat_runs == {} + + def test_a_paused_schema_target_still_has_nothing_to_request(self, sp, monkeypatch): + """Not requestable comes before the pause: a Continue would ask for nothing, and a force with no tier + must not clear a pause that holds back the demand requests.""" + session = _limited(sp, monkeypatch, "auto", SCHEMA_BY_SIZE) + session.cost_paused = True + session_mod.begin_stats_generation(session) + for fields in ({}, {"force": True}): + reply = _request(session, _client(ONDEMAND_CAPS), **fields) + assert (reply["type"], reply["reason"]) == ("stats_aborted", "not_requestable"), fields + assert session.cost_paused is True + assert (session.stats_status, session.stats_reason) == ("not_computed", "cost") + + def test_the_ceiling_is_answered_before_the_pause(self, sp, monkeypatch): + """Continuing would not help a request the ceiling refuses.""" + session = _limited(sp, monkeypatch, "full", FULL_OVER_CEILING) + session.cost_paused = True + session_mod.begin_stats_generation(session) + reply = _request(session, _client(ONDEMAND_CAPS), tier="full", force=True) + assert _refusal_fields(reply)["reason"] == "ceiling" + reply = _request(session, _client(ONDEMAND_CAPS), tier="full") + assert _refusal_fields(reply)["reason"] == "ceiling" + + +class TestEffectiveStatsPolicy: + """The policy in force for the session's state: the one resolved at load, + raised to the tier a forced request asked for while the state is unfiltered + (or the force was made in this generation).""" + + def test_with_no_override_it_is_the_policy_resolved_at_load(self, sp): + session = _session(sp, "auto", "deferred", SCHEMA_BY_SIZE) + assert session_mod.effective_stats_policy(session) is session.stats_policy + + def test_a_session_with_no_policy_has_none(self, sp): + assert session_mod.effective_stats_policy(_session(sp, "full", "inline", resolve=False)) is None + + @pytest.mark.parametrize("override, expected", [ + ("full", {"tier_target": "full", "auto_request": True, "requestable": [], "reason": None}), + ("scalar", {"tier_target": "scalar", "auto_request": True, "requestable": ["full"], "reason": "size"})]) + def test_a_forced_tier_raises_the_target_and_works_out_the_rest(self, sp, override, expected): + session = _with_frame(_session(sp, "auto", "deferred", SCHEMA_BY_SIZE)) + session.stats_override, session.stats_override_gen = override, session.stats_gen + policy = session_mod.effective_stats_policy(session) + assert {key: policy[key] for key in expected} == expected + assert policy["estimate"] == ESTIMATE + assert (session.stats_policy["tier_target"], session.stats_policy["auto_request"]) == ("schema", False), ( + "the policy resolved at load is not changed") + + @pytest.mark.parametrize("override", ["scalar", "full"]) + def test_an_override_never_lowers_the_target(self, sp, override): + session = _with_frame(_session(sp, "auto", "deferred")) + assert session.stats_policy["tier_target"] == "full" + session.stats_override, session.stats_override_gen = override, session.stats_gen + assert session_mod.effective_stats_policy(session)["tier_target"] == "full" + + def test_the_ceiling_still_holds_for_the_override(self, sp, monkeypatch): + session = _limited(sp, monkeypatch, "auto", {**SCHEMA_BY_SIZE, "ceiling_full_rows": 3}) + session.stats_override, session.stats_override_gen = "full", session.stats_gen + policy = session_mod.effective_stats_policy(session) + assert (policy["tier_target"], policy["requestable"]) == ("scalar", []) + + def test_the_override_does_not_reach_a_later_filtered_generation(self, sp): + session = _with_frame(_session(sp, "auto", "deferred", SCHEMA_BY_SIZE)) + session.stats_override, session.stats_override_gen = "full", session.stats_gen + assert session_mod.effective_stats_policy(session)["tier_target"] == "full" + _with_frame(session, [SEARCH_OP]) + session_mod.begin_stats_generation(session) + assert session_mod.effective_stats_policy(session) is session.stats_policy + assert (session.stats_status, session.stats_reason) == ("not_computed", "size") + + def test_the_override_reaches_a_later_unfiltered_generation(self, sp): + session = _with_frame(_session(sp, "auto", "deferred", SCHEMA_BY_SIZE)) + session.stats_override, session.stats_override_gen = "full", session.stats_gen + _with_frame(session, [CLEANING_OP]) + session_mod.begin_stats_generation(session) + assert session_mod.effective_stats_policy(session)["tier_target"] == "full" + assert (session.stats_status, session.stats_reason) == ("pending", None) + + def test_a_force_made_in_a_filtered_generation_applies_to_that_generation(self, sp): + session = _with_frame(_session(sp, "auto", "deferred", SCHEMA_BY_SIZE), [SEARCH_OP]) + session.stats_override, session.stats_override_gen = "full", session.stats_gen + assert session_mod.effective_stats_policy(session)["tier_target"] == "full" + session_mod.begin_stats_generation(session) + assert session_mod.effective_stats_policy(session)["tier_target"] == "schema" + # Back to an unfiltered state, the override applies again. + _with_frame(session, []) + session_mod.begin_stats_generation(session) + assert session_mod.effective_stats_policy(session)["tier_target"] == "full" + + def test_a_host_named_schema_session_headed_for_full_is_pending_even_with_inline_delivery(self, sp): + session = _with_frame(_session(sp, "schema", "inline")) + assert (session.stats_status, session.stats_reason) == ("not_computed", "host") + session.stats_override, session.stats_override_gen = "full", session.stats_gen + session_mod.begin_stats_generation(session) + assert (session.stats_status, session.stats_reason) == ("pending", None) + + def test_df_meta_stats_reports_the_effective_policy(self, sp): + session = _with_frame(_session(sp, "auto", "deferred", SCHEMA_BY_SIZE)) + session.stats_override, session.stats_override_gen = "full", session.stats_gen + session_mod.begin_stats_generation(session) + assert session_mod.stats_meta(session) == {"status": "pending", "tier": "schema", "gen": 2, + "tier_target": "full", "requestable": [], "estimate": ESTIMATE} + scalar = _with_frame(_session(sp, "auto", "deferred", SCHEMA_BY_SIZE)) + scalar.stats_override, scalar.stats_override_gen = "scalar", scalar.stats_gen + assert session_mod.stats_meta(scalar) == {"status": "not_computed", "tier": "schema", "gen": 1, + "reason": "size", "tier_target": "scalar", "estimate": ESTIMATE} + + def test_a_scalar_override_makes_scalar_the_tier_a_request_with_no_tier_is_served(self, sp): + session = _with_frame(_session(sp, "auto", "deferred", SCHEMA_BY_SIZE)) + assert stats_wire.serves_scalar_tier(session, _client(ONDEMAND_CAPS)) is False + session.stats_override, session.stats_override_gen = "scalar", session.stats_gen + assert stats_wire.serves_scalar_tier(session, _client(ONDEMAND_CAPS)) is True + + def test_resetting_the_controls_clears_the_override_and_the_pause(self, sp): + session = _with_frame(_session(sp, "auto", "deferred", SCHEMA_BY_SIZE)) + session.stats_override, session.stats_override_gen, session.cost_paused = "full", 4, True + session_mod.reset_stats_controls(session) + assert (session.stats_override, session.stats_override_gen, session.cost_paused) == (None, 0, False) + + +class TestDemandColumns: + """``demand_columns``: the columns a built display config needs min and max + for now, read from the config alone (no data query).""" + + @staticmethod + def _display(*column_configs, summary=()): + return {"main": {"df_viewer_config": {"column_config": list(column_configs)}}, + "summary": {"df_viewer_config": {"column_config": list(summary)}}} + + @staticmethod + def _rule(val_column="a", color_rule="color_map", **extra): + rule = {"color_rule": color_rule, "map_name": "DIVERGING_RED_WHITE_BLUE", **extra} + if val_column is not None: + rule["val_column"] = val_column + return {"col_name": "x", "color_map_config": rule} + + def test_a_color_map_rule_names_the_column_it_reads(self, sp): + assert sp.demand_columns(self._display(self._rule("a")), PAIRS) == ["a"] + + def test_a_val_column_that_is_an_original_name_is_mapped_to_the_key_the_stats_use(self, sp): + """A klass writes the short alias or a name it read from the sd; the stats are keyed by the rewritten name.""" + assert sp.demand_columns(self._display(self._rule("price"), self._rule("category")), PAIRS) == ["a", "c"] + + def test_a_rewritten_name_wins_over_an_original_name_that_looks_like_one(self, sp): + pairs = [("b", "a"), ("a", "b")] + assert sp.demand_columns(self._display(self._rule("a")), pairs) == ["a"] + + @pytest.mark.parametrize("rule", [ + {"color_rule": "color_categorical", "map_name": "BLUE_TO_YELLOW", "val_column": "a"}, + {"color_rule": "color_categorical", "map_name": "BLUE_TO_YELLOW"}, + {"color_rule": "color_from_column", "val_column": "a"}, + {"color_rule": "color_not_null", "conditional_color": "red", "exist_column": "a"}, + {"color_rule": "color_static", "color": "red"}, + # The client reads nothing for a color_map with no val_column. + {"color_rule": "color_map", "map_name": "BLUE_TO_YELLOW"}]) + def test_only_a_numeric_color_map_rule_creates_demand(self, sp, rule): + assert sp.demand_columns(self._display({"col_name": "x", "color_map_config": rule}), PAIRS) == [] + + def test_a_tooltip_val_column_creates_no_demand(self, sp): + config = {"col_name": "x", "tooltip_config": {"tooltip_type": "simple", "val_column": "a"}} + assert sp.demand_columns(self._display(config), PAIRS) == [] + + def test_a_val_column_that_is_not_a_column_of_the_table_is_dropped(self, sp): + assert sp.demand_columns(self._display(self._rule("zz"), self._rule("a")), PAIRS) == ["a"] + + def test_the_columns_come_once_each_in_the_frame_s_order(self, sp): + display = self._display(self._rule("c"), self._rule("a"), self._rule("c"), self._rule("price")) + assert sp.demand_columns(display, PAIRS) == ["a", "c"] + + def test_every_display_is_scanned(self, sp): + display = self._display(self._rule("a"), summary=[self._rule("b")]) + assert sp.demand_columns(display, PAIRS) == ["a", "b"] + + @pytest.mark.parametrize("display", [None, {}, {"main": None}, {"main": {}}, {"main": {"df_viewer_config": None}}, + {"main": {"df_viewer_config": {"column_config": None}}}, + {"main": {"df_viewer_config": {"column_config": ["x", None, {"color_map_config": None}, + {"color_map_config": "a"}, {"color_map_config": {"color_rule": "color_map", "val_column": 3}}, + {"color_map_config": {"color_rule": "color_map", "val_column": ["a"]}}, + {"color_map_config": {"color_rule": "color_map", "val_column": {"a": 1}}}]}}}]) + def test_a_config_that_is_not_the_usual_shape_yields_nothing_and_does_not_raise(self, sp, display): + assert sp.demand_columns(display, PAIRS) == [] + + def test_the_config_is_not_changed(self, sp): + display = self._display(self._rule("price")) + before = json.dumps(display, sort_keys=True) + sp.demand_columns(display, PAIRS) + assert json.dumps(display, sort_keys=True) == before + + def test_no_columns_no_demand(self, sp): + assert sp.demand_columns(self._display(self._rule("a")), []) == [] + + +# --------------------------------------------------------------------------- +# Guards for huge sources (rows-first p37) +# --------------------------------------------------------------------------- + +GUARD_ENV_FIELDS = {"BUCKAROO_SORT_DISABLE_ROWS": "sort_disable_rows", + "BUCKAROO_SEARCH_DISABLE_ROWS": "search_disable_rows"} +# The provisional defaults, written out so a change to a module constant has to +# change a test too. Search has no default threshold: the server never refuses a +# search (plan 3 question 7), so the flag is only ever turned off by a host. +GUARD_DEFAULTS = {"sort_disable_rows": 25_000_000, "search_disable_rows": None} +ENABLED = {"sort": "enabled", "search": "enabled"} + + +@pytest.fixture(autouse=True) +def clean_guard_env(monkeypatch): + """A developer's own sort and search thresholds must not leak in.""" + for name in GUARD_ENV_FIELDS: + monkeypatch.delenv(name, raising=False) + + +def _guard_api(sp, name): + api = getattr(sp, name, None) + assert api is not None, f"buckaroo.server.stats_policy.{name} does not exist" + return api + + +def _guards(sp, backend, rows, cols=10, source_kind="parquet", **limits): + """``resolve_source_guards`` on the module defaults, or on the limits given.""" + resolve = _guard_api(sp, "resolve_source_guards") + if limits: + return resolve(backend, source_kind, rows, cols, limits=_guard_api(sp, "GuardLimits")(**limits)) + return resolve(backend, source_kind, rows, cols) + + +class TestGuardLimits: + def test_the_defaults_are_the_provisional_values(self, sp): + assert dataclasses.asdict(_guard_api(sp, "GuardLimits")()) == GUARD_DEFAULTS + assert getattr(sp, "DEFAULT_SORT_DISABLE_ROWS", None) == GUARD_DEFAULTS["sort_disable_rows"] + assert getattr(sp, "DEFAULT_SEARCH_DISABLE_ROWS", "missing") is None + + def test_from_env_with_a_clean_environment_is_the_default_set(self, sp): + assert dataclasses.asdict(_guard_api(sp, "GuardLimits").from_env()) == GUARD_DEFAULTS + + def test_the_stats_limits_do_not_carry_the_guard_thresholds(self, sp): + """They are separate numbers (plan 3 section 3.5): a threshold on stats + says nothing about the cost of a sorted window.""" + assert not {"sort_disable_rows", "search_disable_rows"} & {f.name for f in dataclasses.fields(sp.StatsLimits)} + + @pytest.mark.parametrize("name, field", GUARD_ENV_FIELDS.items()) + def test_each_variable_overrides_its_field(self, sp, monkeypatch, name, field): + monkeypatch.setenv(name, "12345") + assert getattr(_guard_api(sp, "GuardLimits").from_env(), field) == 12345 + + def test_underscored_integers_are_accepted(self, sp, monkeypatch): + monkeypatch.setenv("BUCKAROO_SORT_DISABLE_ROWS", "2_000_000") + assert _guard_api(sp, "GuardLimits").from_env().sort_disable_rows == 2_000_000 + + @pytest.mark.parametrize("name, field", GUARD_ENV_FIELDS.items()) + @pytest.mark.parametrize("bad", ["lots", "-5", "1.5"]) + def test_invalid_value_falls_back_to_the_default_and_warns(self, sp, monkeypatch, caplog, name, field, bad): + monkeypatch.setenv(name, bad) + with caplog.at_level(logging.WARNING): + limits = _guard_api(sp, "GuardLimits").from_env() + assert getattr(limits, field) == GUARD_DEFAULTS[field] + assert name in caplog.text + + def test_empty_value_means_unset(self, sp, monkeypatch): + monkeypatch.setenv("BUCKAROO_SORT_DISABLE_ROWS", "") + assert _guard_api(sp, "GuardLimits").from_env().sort_disable_rows == GUARD_DEFAULTS["sort_disable_rows"] + + +# (backend, rows, limits) -> (sort, search). 10.8M x 43 is parking_2017; the +# 42.3M, 54.1M and 78.0M entries are the three tallyman loads over 70 s. +SOURCE_GUARD_TABLE = [ + ("xorq", 0, {}, ("enabled", "enabled")), + ("xorq", 52_814, {}, ("enabled", "enabled")), + ("xorq", 10_800_000, {}, ("enabled", "enabled")), + ("xorq", 24_999_999, {}, ("enabled", "enabled")), + ("xorq", 25_000_000, {}, ("enabled", "enabled")), + ("xorq", 25_000_001, {}, ("disabled", "enabled")), + ("xorq", 42_300_000, {}, ("disabled", "enabled")), + ("xorq", 54_100_000, {}, ("disabled", "enabled")), + ("xorq", 78_000_000, {}, ("disabled", "enabled")), + ("xorq", 10**12, {}, ("disabled", "enabled")), + # Eager backends keep both: their windows come from a bounded frame. + ("pandas", 78_000_000, {}, ("enabled", "enabled")), + ("polars", 78_000_000, {}, ("enabled", "enabled")), + ("polars", 78_000_000, {"sort_disable_rows": 1, "search_disable_rows": 1}, ("enabled", "enabled")), + # The sort threshold: equal, one above, and none at all. + ("xorq", 1000, {"sort_disable_rows": 1000}, ("enabled", "enabled")), + ("xorq", 1001, {"sort_disable_rows": 1000}, ("disabled", "enabled")), + ("xorq", 10**12, {"sort_disable_rows": None}, ("enabled", "enabled")), + # The search threshold is its own number. + ("xorq", 1000, {"search_disable_rows": 1000}, ("enabled", "enabled")), + ("xorq", 1001, {"search_disable_rows": 1000}, ("enabled", "disabled")), + ("xorq", 1001, {"sort_disable_rows": 5000, "search_disable_rows": 1000}, ("enabled", "disabled")), + ("xorq", 5001, {"sort_disable_rows": 5000, "search_disable_rows": 1000}, ("disabled", "disabled")), +] + + +def _source_guard_id(case): + backend, rows, limits, _expected = case + return f"{backend}-{rows}-{'-'.join(f'{k}={v}' for k, v in limits.items()) or 'defaults'}" + + +class TestResolveSourceGuards: + @pytest.mark.parametrize("backend, rows, limits, expected", SOURCE_GUARD_TABLE, + ids=[_source_guard_id(case) for case in SOURCE_GUARD_TABLE]) + def test_the_table(self, sp, backend, rows, limits, expected): + assert _guards(sp, backend, rows, **limits) == dict(zip(("sort", "search"), expected)) + + def test_the_result_has_exactly_the_two_flags(self, sp): + assert set(_guards(sp, "xorq", 5)) == {"sort", "search"} + + def test_the_column_count_does_not_move_a_row_threshold(self, sp): + assert _guards(sp, "xorq", 25_000_000, cols=1) == _guards(sp, "xorq", 25_000_000, cols=500) == ENABLED + + def test_the_guards_ignore_the_stats_thresholds(self, sp, monkeypatch): + """A ceiling that refuses full stats for the entry leaves its sort alone.""" + monkeypatch.setenv("BUCKAROO_STATS_FULL_AUTO_ROWS", "1") + monkeypatch.setenv("BUCKAROO_STATS_CEILING_FULL_ROWS", "1") + monkeypatch.setenv("BUCKAROO_STATS_CEILING_SCALAR_CELLS", "1") + assert _guards(sp, "xorq", 5_000) == ENABLED + + def test_the_stats_policy_ignores_the_guard_thresholds(self, sp, monkeypatch): + monkeypatch.setenv("BUCKAROO_SORT_DISABLE_ROWS", "1") + monkeypatch.setenv("BUCKAROO_SEARCH_DISABLE_ROWS", "1") + assert sp.resolve_stats_policy("xorq", "parquet", 5_000, 10)["tier_target"] == "full" + + def test_the_environment_is_read_on_each_call(self, sp): + resolve = _guard_api(sp, "resolve_source_guards") + assert resolve("xorq", "parquet", 5_000, 10) == ENABLED + with pytest.MonkeyPatch.context() as env: + env.setenv("BUCKAROO_SORT_DISABLE_ROWS", "1000") + assert resolve("xorq", "parquet", 5_000, 10)["sort"] == "disabled" + assert resolve("xorq", "parquet", 5_000, 10) == ENABLED + + def test_the_environment_sets_the_search_flag(self, sp, monkeypatch): + monkeypatch.setenv("BUCKAROO_SEARCH_DISABLE_ROWS", "1000") + assert _guard_api(sp, "resolve_source_guards")("xorq", "parquet", 5_000, 10) == {"sort": "enabled", + "search": "disabled"} + + @pytest.mark.parametrize("rows, cols, error", [(-1, 3, ValueError), (5, -1, ValueError), (1.5, 3, TypeError), + ("5", 3, TypeError), (5, None, TypeError)]) + def test_a_bad_count_is_refused_as_the_stats_policy_refuses_it(self, sp, rows, cols, error): + with pytest.raises(error): + _guard_api(sp, "resolve_source_guards")("xorq", "parquet", rows, cols) + + def test_a_source_kind_that_is_not_a_string_is_refused(self, sp): + with pytest.raises(TypeError): + _guard_api(sp, "resolve_source_guards")("xorq", None, 5, 3) + + +def _guarded(sort="enabled", search="enabled"): + """A buckaroo-mode session whose load resolved these guard flags.""" + session = session_mod.SessionState(session_id="s", path="p") + session.mode = "buckaroo" + session.df_meta = {"total_rows": 5} + session.source_guards = {"sort": sort, "search": search} + return session + + +class TestGuardFlagsInDfMeta: + """``df_meta.sort`` and ``df_meta.search``: sent when a session has turned + one off, left out when it is on, which is what a client assumes.""" + + def test_a_session_with_no_guards_by_default(self): + assert getattr(session_mod.SessionState(session_id="s", path="p"), "source_guards", "missing") is None + + def test_the_documented_defaults(self): + assert getattr(session_mod, "SOURCE_GUARD_DEFAULTS", None) == ENABLED + + def test_a_disabled_sort_is_reported(self): + assert session_mod.build_state_message(_guarded(sort="disabled"))["df_meta"] == {"total_rows": 5, + "sort": "disabled"} + + def test_a_disabled_search_is_reported(self): + assert session_mod.build_state_message(_guarded(search="disabled"))["df_meta"] == {"total_rows": 5, + "search": "disabled"} + + def test_both_flags_are_reported_when_both_are_off(self): + assert session_mod.build_state_message(_guarded("disabled", "disabled"))["df_meta"] == {"total_rows": 5, + "sort": "disabled", "search": "disabled"} + + @pytest.mark.parametrize("session", [_guarded(), session_mod.SessionState(session_id="s", path="p")], + ids=["enabled", "unresolved"]) + def test_enabled_flags_leave_the_message_as_it_was(self, session): + session.df_meta = {"total_rows": 5} + assert session_mod.build_state_message(session)["df_meta"] == {"total_rows": 5} + + @pytest.mark.parametrize("ondemand", [True, False]) + def test_every_client_is_told(self, ondemand): + """The server refuses the sort whoever asks, so no capability gates the flag.""" + message = session_mod.build_state_message(_guarded(sort="disabled"), ondemand=ondemand) + assert message["df_meta"]["sort"] == "disabled" + + def test_the_flags_ride_with_the_stats_status(self, sp): + session = _session(sp, "auto", "deferred", SCALAR_BY_SIZE) + session.source_guards = {"sort": "disabled", "search": "enabled"} + df_meta = session_mod.build_state_message(session)["df_meta"] + assert df_meta == {"total_rows": 5, "sort": "disabled", "stats": session_mod.stats_meta(session)} + + def test_the_session_s_df_meta_is_not_changed(self): + session = _guarded("disabled", "disabled") + session_mod.build_state_message(session) + assert session.df_meta == {"total_rows": 5} + + def test_a_client_reads_what_is_left_out_as_enabled(self): + read = getattr(session_mod, "guards_with_defaults", None) + assert read is not None, "session.guards_with_defaults does not exist" + assert read({"total_rows": 5}) == ENABLED + assert read({}) == ENABLED + assert read(None) == ENABLED + assert read({"sort": "disabled"}) == {"sort": "disabled", "search": "enabled"} + assert read({"search": "disabled", "sort": "enabled"}) == {"sort": "enabled", "search": "disabled"} + + +class TestSortRefusal: + """``sort_refusal``: the ``infinite_resp`` a sorted window gets when the + session's sort is off, and nothing for any other request.""" + + WINDOW = {"start": 0, "end": 5, "sourceName": "default", "origEnd": 5} + + @staticmethod + def _refusal(session, payload): + refuse = getattr(session_mod, "sort_refusal", None) + assert refuse is not None, "session.sort_refusal does not exist" + return refuse(session, payload) + + def test_a_sorted_window_is_refused_with_a_code(self): + payload = {**self.WINDOW, "sort": "a", "sort_direction": "asc"} + refusal = self._refusal(_guarded(sort="disabled"), payload) + assert refusal is not None + assert (refusal["type"], refusal["key"], refusal["length"]) == ("infinite_resp", payload, 0) + assert refusal["error_code"] == "sort_disabled" + assert isinstance(refusal["error_info"], str) and refusal["error_info"] + assert "payload" not in refusal, "no rows travel with a refusal" + + @pytest.mark.parametrize("payload", [{}, {"sort": None}, {"sort": ""}, {"sort_direction": "asc"}]) + def test_a_window_with_no_sort_is_served(self, payload): + assert self._refusal(_guarded(sort="disabled"), {**self.WINDOW, **payload}) is None + + def test_a_session_with_the_sort_on_serves_a_sorted_window(self): + assert self._refusal(_guarded(), {**self.WINDOW, "sort": "a"}) is None + assert self._refusal(_guarded(search="disabled"), {**self.WINDOW, "sort": "a"}) is None + + def test_a_session_that_resolved_no_guards_serves_a_sorted_window(self): + session = session_mod.SessionState(session_id="s", path="p") + assert self._refusal(session, {**self.WINDOW, "sort": "a"}) is None + + @pytest.mark.parametrize("payload", [None, "sort", ["sort"], 5]) + def test_a_payload_that_is_not_a_dict_is_not_refused(self, payload): + assert self._refusal(_guarded(sort="disabled"), payload) is None + + +class TestResolveSessionGuards: + """``stats_wire.resolve_session_guards``: what a load handler resolves, and + for which sessions.""" + + @staticmethod + def _resolve(*args): + resolve = getattr(stats_wire, "resolve_session_guards", None) + assert resolve is not None, "stats_wire.resolve_session_guards does not exist" + return resolve(*args) + + def test_a_schema_dataflow_resolves_against_the_count_it_has(self, monkeypatch): + monkeypatch.setenv("BUCKAROO_SORT_DISABLE_ROWS", "3") + assert self._resolve("schema", 5, 3) == {"sort": "disabled", "search": "enabled"} + assert self._resolve("schema", 3, 3) == ENABLED + + def test_a_dataflow_that_ran_its_stats_has_no_guards(self, monkeypatch): + """Only a session a host opened with a stats policy is guarded. One built + the way it always was, with its stats inline, stays as it was.""" + monkeypatch.setenv("BUCKAROO_SORT_DISABLE_ROWS", "3") + assert self._resolve("full", 5, 3) is None + assert self._resolve("full", 5_000_000_000, 40) is None + + def test_the_defaults_guard_a_session_of_the_size_the_stats_ceiling_refuses(self): + assert self._resolve("schema", 78_000_000, 44)["sort"] == "disabled" + assert self._resolve("schema", 10_800_000, 43) == ENABLED + + def test_the_thresholds_are_read_on_each_call(self, monkeypatch): + assert self._resolve("schema", 5, 3) == ENABLED + monkeypatch.setenv("BUCKAROO_SORT_DISABLE_ROWS", "3") + assert self._resolve("schema", 5, 3)["sort"] == "disabled" + + +def _grid_display(main_columns, left=(), summary_columns=()): + """A ``df_display_args`` as the dataflow builds it: ``main`` is the display + the infinite grid serves from the server, ``summary`` is a client-side grid.""" + def display(data_key, columns, left_columns): + return {"data_key": data_key, "summary_stats_key": "all_stats", + "df_viewer_config": {"pinned_rows": [], "column_config": list(columns), + "left_col_configs": list(left_columns), "extra_grid_config": {}, "component_config": {}}} + return {"main": display("main", main_columns, left), "summary": display("empty", summary_columns, ())} + + +class TestDisableSorting: + """``disable_sorting``: the final pass that turns sorting off for every column + of a display served by ``infinite_request``, after the klasses and the + overrides have had their say.""" + + @staticmethod + def _disable(sp, display): + return _guard_api(sp, "disable_sorting")(display) + + @staticmethod + def _columns(display, key="main"): + config = display[key]["df_viewer_config"] + return config["column_config"] + config["left_col_configs"] + + def test_every_column_of_the_infinite_display_stops_sorting(self, sp): + display = _grid_display( + [{"col_name": "a", "header_name": "price"}, + {"col_name": "b", "header_name": "qty", "ag_grid_specs": {"minWidth": 90}}, + {"col_name": "c", "header_name": "category", "ag_grid_specs": {"sortable": True}}, + {"col_path": ("x", "y"), "field": "d", "ag_grid_specs": {"sortable": True, "minWidth": 70}}], + left=[{"col_name": "index", "header_name": "index"}]) + out = self._disable(sp, display) + assert [c["ag_grid_specs"]["sortable"] for c in self._columns(out)] == [False] * 5 + + def test_the_other_column_specs_are_kept(self, sp): + out = self._disable(sp, _grid_display([{"col_name": "b", "ag_grid_specs": {"minWidth": 90, "sortable": True}}])) + assert out["main"]["df_viewer_config"]["column_config"][0]["ag_grid_specs"] == {"minWidth": 90, + "sortable": False} + + def test_specs_that_are_not_a_dict_are_replaced(self, sp): + out = self._disable(sp, _grid_display([{"col_name": "a", "ag_grid_specs": None}])) + assert out["main"]["df_viewer_config"]["column_config"] == [{"col_name": "a", + "ag_grid_specs": {"sortable": False}}] + + def test_a_column_entry_that_is_not_a_dict_is_left_as_it_is(self, sp): + out = self._disable(sp, _grid_display([None, "a", {"col_name": "b"}])) + assert out["main"]["df_viewer_config"]["column_config"] == [None, "a", {"col_name": "b", + "ag_grid_specs": {"sortable": False}}] + + def test_a_display_the_client_sorts_itself_is_left_alone(self, sp): + summary = [{"col_name": "a", "header_name": "price", "ag_grid_specs": {"sortable": True}}, {"col_name": "b"}] + display = _grid_display([{"col_name": "a"}], summary_columns=summary) + out = self._disable(sp, display) + assert out["summary"] == display["summary"] + + def test_the_rest_of_the_display_is_unchanged(self, sp): + display = _grid_display([{"col_name": "a", "displayer_args": {"displayer": "obj"}}]) + out = self._disable(sp, display) + main, held = out["main"], display["main"] + assert (main["data_key"], main["summary_stats_key"]) == (held["data_key"], held["summary_stats_key"]) + for key in ("pinned_rows", "extra_grid_config", "component_config"): + assert main["df_viewer_config"][key] == held["df_viewer_config"][key] + assert main["df_viewer_config"]["column_config"][0]["displayer_args"] == {"displayer": "obj"} + + def test_the_display_given_is_not_changed(self, sp): + display = _grid_display([{"col_name": "a", "ag_grid_specs": {"sortable": True}}, {"col_name": "b"}], + left=[{"col_name": "index"}]) + before = json.dumps(display, sort_keys=True) + out = self._disable(sp, display) + assert json.dumps(display, sort_keys=True) == before + assert out is not display + + @pytest.mark.parametrize("display", [{}, {"main": None}, {"main": {}}, {"main": {"data_key": "main"}}, + {"main": {"data_key": "main", "df_viewer_config": None}}, + {"main": {"data_key": "main", "df_viewer_config": {"column_config": None}}}, + {"main": {"data_key": "main", "df_viewer_config": {"column_config": []}}}]) + def test_a_display_that_is_not_the_usual_shape_does_not_raise(self, sp, display): + assert isinstance(self._disable(sp, display), dict) + + def test_a_display_with_no_left_columns_is_handled(self, sp): + display = _grid_display([{"col_name": "a"}]) + del display["main"]["df_viewer_config"]["left_col_configs"] + out = self._disable(sp, display) + assert out["main"]["df_viewer_config"]["column_config"][0]["ag_grid_specs"]["sortable"] is False 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..8f07c4938 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,243 @@ 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')) + + @pytest.mark.parametrize('backend', ['pandas', 'polars']) + def test_the_scalar_tier_has_no_pandas_or_polars_units(self, backend): + """The scalar tier is xorq's. A state that asks for it must not be + answered with full-tier units.""" + pipeline = StatPipeline(_stat_lists()[backend], unit_test=False) + with pytest.raises(ValueError, match='full tier'): + pipeline.plan(StatState(_mixed_frames()[backend], tier='scalar')) 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..9f5c35882 100644 --- a/tests/unit/test_xorq_stats_v2.py +++ b/tests/unit/test_xorq_stats_v2.py @@ -14,11 +14,14 @@ xo = pytest.importorskip("xorq.api") +from xorq.vendor.ibis.expr import operations as ops # noqa: E402 + from buckaroo.pluggable_analysis_framework import perf_log # noqa: E402 from buckaroo.pluggable_analysis_framework.xorq_stat_pipeline import ( # noqa: E402 XorqStatPipeline, XorqColumn) from buckaroo.pluggable_analysis_framework.stat_func import stat # noqa: E402 +from buckaroo.pluggable_analysis_framework.stat_units import StatState, StatUnit, merge_fragments # noqa: E402 from buckaroo.customizations.xorq_stats_v2 import ( # noqa: E402 XORQ_STATS_V2) @@ -876,3 +879,529 @@ 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) + + +# ============================================================ +# The scalar tier (rows-first p34) +# ============================================================ + +# What the full tier reports and the scalar tier does not. +_FULL_ONLY = {"median", "distinct_count", "distinct_per", "histogram"} + + +def _tier_run(table, tier, **pipeline_kwargs): + """Every unit of a run of ``tier``, run in order: ``(acc, [(unit, fragment)])``.""" + return _run_units(XorqStatPipeline(XORQ_STATS_V2, unit_test=False, **pipeline_kwargs), StatState(table, tier=tier)) + + +def _union(run): + return merge_fragments(fragment for _unit, fragment in run[1]) + + +class TestScalarTier: + """The scalar tier of a xorq run: the scalar class of the batch without + ``approx_median`` and ``distinct_count``, and no histogram query. Its + ``histogram_bins`` come from the batch's ``min`` and ``max``, so a + ``color_map`` column needs no further query.""" + + _ALL = ("ints", "floats", "strs", "bools") + + def test_an_unknown_tier_is_refused(self): + with pytest.raises(ValueError, match="tier"): + StatState(_make_table(), tier="median") + + def test_the_scalar_plan_is_the_batch_alone(self): + units = XorqStatPipeline(XORQ_STATS_V2, unit_test=False).plan(StatState(_make_table(), tier="scalar")) + assert [(u.id, u.phase, u.cost, u.after) for u in units] == [("batch", "batch", "scan", ())] + assert units[0].columns == self._ALL + + def test_a_scalar_run_issues_the_batch_query_and_no_histogram_query(self, monkeypatch): + spy = _QuerySpy(monkeypatch) + _tier_run(_make_table(), "scalar") + assert spy.kinds() == ["batch"] + + def test_the_scalar_batch_has_no_approx_median_and_no_distinct_count(self, monkeypatch): + spy = _QuerySpy(monkeypatch) + _tier_run(_make_table(), "scalar") + (batch,) = spy.queries + # ApproxCountDistinct is a CountDistinct, so this finds both distinct aggregates. + assert not list(batch.op().find((ops.ApproxMedian, ops.CountDistinct))) + # The same search finds them in the full batch, so it is looking at the right thing. + spy.queries.clear() + _tier_run(_make_table(), "full") + assert list(spy.queries[0].op().find(ops.ApproxMedian)) and list(spy.queries[0].op().find(ops.CountDistinct)) + + def test_the_scalar_stats_are_the_full_stats_without_the_keys_the_tier_leaves_out(self): + table = _make_table() + scalar, full = _union(_tier_run(table, "scalar")), _union(_tier_run(table, "full")) + for col in self._ALL: + assert set(scalar[col]) == set(full[col]) - _FULL_ONLY, col + assert _stable(scalar[col]) == _stable({key: full[col][key] for key in scalar[col]}), col + assert {"length", "null_count", "min", "max", "mean", "std", "non_null_count", "nan_per", + "histogram_bins", "_type"} <= set(scalar["ints"]) + + def test_the_accumulator_reports_what_the_fragments_do(self): + acc, run = _tier_run(_make_table_with_nulls(), "scalar") + assert _stable(acc.sd()) == _stable(_union((acc, run))) + assert not any(_FULL_ONLY & set(stats) for stats in acc.sd().values()) + assert acc.errors == [] + + def test_histogram_bins_come_with_the_batch_and_equal_the_full_tier_s(self, monkeypatch): + table = _make_table() + spy = _QuerySpy(monkeypatch) + scalar = _union(_tier_run(table, "scalar")) + assert spy.kinds() == ["batch"] + full = _union(_tier_run(table, "full")) + for col in ("ints", "floats"): + assert len(scalar[col]["histogram_bins"]) == 11, col + assert scalar[col]["histogram_bins"] == full[col]["histogram_bins"], col + assert scalar["strs"]["histogram_bins"] == scalar["bools"]["histogram_bins"] == [] + + def test_a_low_cardinality_integer_gets_bins_the_full_tier_does_not(self): + """Without ``distinct_count`` the cardinality is unknown and the column + is treated as one that wants bins. The full tier counts, finds five or + fewer values, and sends none.""" + table = xo.memtable(pd.DataFrame({"few": [1, 2, 3, 1, 2, 3, 1], "many": [1, 2, 3, 4, 5, 6, 7]})) + scalar, full = _union(_tier_run(table, "scalar")), _union(_tier_run(table, "full")) + assert len(scalar["few"]["histogram_bins"]) == 11 + assert full["few"]["histogram_bins"] == [] + assert scalar["many"]["histogram_bins"] == full["many"]["histogram_bins"] + + def test_a_skipped_column_has_no_scalar_unit_and_keeps_its_structural_entry(self): + pipeline = XorqStatPipeline(XORQ_STATS_V2, unit_test=False) + state = StatState(_make_table(), frozenset({"strs"}), tier="scalar") + assert pipeline.plan(state)[0].columns == ("ints", "floats", "bools") + acc, _run = _run_units(pipeline, state) + assert set(acc.sd()["strs"]) == {"orig_col_name", "rewritten_col_name", "dtype", "length", "min", "max"} + assert acc.sd()["strs"]["length"] == 7 + + def test_a_chunked_scalar_batch_equals_the_single_batch(self, tmp_path): + scan = _six_column_scan(tmp_path) + single = _union(_tier_run(scan, "scalar")) + pipeline = _chunked(2 * _SIX_ROWS) + state = StatState(scan, rows=_SIX_ROWS, tier="scalar") + assert [u.id for u in pipeline.plan(state)] == ["batch:0", "batch:1", "batch:2"] + assert _stable(_union(_run_units(pipeline, state))) == _stable(single) + + def test_the_scalar_tier_runs_no_histogram_unit(self): + pipeline = XorqStatPipeline(XORQ_STATS_V2, unit_test=False) + acc = pipeline.new_accumulator(StatState(_make_table(), tier="scalar")) + histogram = StatUnit("histogram:ints", ("ints",), "histogram", after=("batch",), cost="query") + with pytest.raises(ValueError, match="scalar"): + pipeline.run(histogram, acc) + + def test_a_stat_that_reads_an_omitted_key_is_left_out_with_it(self): + @stat() + def double_median(median: float) -> float: + return median * 2 + + pipeline = XorqStatPipeline([*XORQ_STATS_V2, double_median], unit_test=False) + acc, run = _run_units(pipeline, StatState(_make_table(), tier="scalar")) + assert not any("double_median" in stats for _unit, fragment in run for stats in fragment.values()) + assert acc.errors == [], "a stat with an input the tier does not have is not run, so it is no error" + full = _union(_run_units(pipeline, StatState(_make_table()))) + assert full["ints"]["double_median"] == 2 * full["ints"]["median"] + + def test_a_stat_that_reads_distinct_count_runs_on_the_unknown(self): + @stat() + def cardinality_unknown(distinct_count: int) -> bool: + return distinct_count is None + + pipeline = XorqStatPipeline([*XORQ_STATS_V2, cardinality_unknown], unit_test=False) + scalar = _union(_run_units(pipeline, StatState(_make_table(), tier="scalar"))) + full = _union(_run_units(pipeline, StatState(_make_table()))) + assert (scalar["ints"]["cardinality_unknown"], full["ints"]["cardinality_unknown"]) == (True, False) + assert "distinct_count" not in scalar["ints"]