Skip to content

feat(stats): stats_request runs units for a time budget, with per-client cursors and the final assignment (rows-first s5) - #1028

Closed
paddymul wants to merge 18 commits into
adr-002-rows-first-stats-deliveryfrom
feat/rowsfirst-s5-stats-request-units
Closed

paddymul wants to merge 18 commits into
adr-002-rows-first-stats-deliveryfrom
feat/rowsfirst-s5-stats-request-units

Conversation

@paddymul

@paddymul paddymul commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #1026 (feat/rowsfirst-s4-stat-units), which is stacked on #1024, #1022 and #1021. This PR is based on main so the repo's Checks workflow runs on it (its pull_request trigger uses branches: "*", which does not match a base branch containing a slash). The diff therefore includes the commits of #1021, #1022, #1024 and #1026 until they merge. The commits of this phase are 2a73c8ed (failing tests) and ad26fdf4 (implementation); read only those two (git diff origin/feat/rowsfirst-s4-stat-units...HEAD).

Problem

stats_request (#1024) is a whole run: one synchronous call that computes every stat before it replies. On a long xorq batch it holds the loop for as long as that takes, and no client sees a stat before the last one is done. #1026 cut a run into units that can be run one at a time, and added the per-session StatRun and the per-connection StatCursor, but nothing in the server calls them. The request branch has to run units for a bounded time per request, answer each client with the fragments it has not seen, and do the final assignment when the last unit completes.

Phase and plan references

Rows-first s5, from buckaroo2-reports/plans/: plan 2 (02-rows-first-xorq-and-lazy-polars.md) section 7 "Phase 3" with sections 4.1 (Request, Final assignment) and 4.3 (reload, telemetry), and plan 1 (01-rows-first-stats-separate-plumbing.md) section 8 (telemetry).

Approach

  • Two shapes of stats_request. {stats_gen, scope, columns?} is still the whole run and answers as in feat(server): stats_request, stats_update and df_meta.stats on deferred sessions (rows-first s3) #1024. {stats_gen, scope, columns?, incremental: true} is a time-boxed step. A value other than a boolean for incremental is stats_aborted with reason bad_request, and is not run as a whole run.
  • An incremental step (stats_wire._serve_units). It takes the session's StatRun for the current generation (start_stat_run, planning only). If this client's cursor is behind the run, the reply carries the fragments it has not seen and no unit runs. Otherwise run_units runs units until STATS_BUDGET_S (75 ms) is spent, at least one, so a unit that is a query is the only one of its request and units that are snapshot-cache hits share a request. columns are the grid's column names (a, b, c) and put the units that cover them first. The reply is stats_update {stats_gen, scope, tier, final: false, remaining, payload, elapsed_ms}. payload is an inline wide DFEnvelope of the columns the unseen fragments cover, assembled the way merged_sd is (CustomizableDataflow._assemble_merged_sd(running_sd), the body of the merged_sd observer with the run's accumulating sd standing in for the scopes that share the filt chain), so init_sd, a processing step's sd and the cleaned_* and filtered_* layers apply. A client merges it key by key.
  • Final assignment (complete_stats, shared by every path). When the last unit has run, in whichever request ran it: write the sd into summary_stats_cache under the full-tier key, then assign summary_sd; refresh the session snapshot through refresh_session_snapshot and set the status to complete in the same step; free the run. A state whose full sd is already in the cache runs no unit. The reply that follows is final: true, remaining: 0, carries the complete all_stats, and carries the rebuilt df_display_args when its digest differs from the one the client holds (a float column's minWidth is 114 with full stats and 78 without in the test fixture). The digest of the config a stats-free frame carried is kept on the handler (display_args_hash, set by build_state_message_for). The client's own search highlight is applied to the config it gets, since the config replaces the one that carries it.
  • Whole run and legacy clients. With no StatRun for the generation, complete_stats is one _get_summary_sd call, as before: it runs the same units in the same order, and it is the hook the xorq dataflow customizes. With a run that a client has made progress on, it runs the units left and assigns the run's results, so no unit runs twice. A legacy client's complete frame does that too.
  • Per-handler cursors. Each connection reads the shared fragment list at its own pace. Two clients that take turns each get the other's fragments as a catch-up that runs nothing, and each unit runs once.
  • Failure. A unit that raises fails the generation (stats_status error, reason stats_failed, runs dropped). It is not retried by the next request, and the next generation starts clean. A run that cannot be planned (a post-processor that fails leaves an error frame, which the xorq stats class cannot plan) falls back to the whole-run path, with a warning.
  • Telemetry. DataStreamHandler._handle_stats_request already binds session.tele_sink. New: a stats.unit span per unit (session, stats_gen, unit, phase, cost, columns); firstpull.stats_total once per completed run, now with units, run_secs and, on xorq, the cache_status, cache_hits, cache_misses, cache_secs, cache_snapshots, cache_bytes and cache_write_errors that firstpull.summary_stats carries for a whole run; the stats.request span gains final, remaining and units. StatRun sums elapsed_s over its units.
  • Reload. /reload_expr bumps stats_gen and drops the run (begin_stats_generation, from feat(server): stats_request, stats_update and df_meta.stats on deferred sessions (rows-first s3) #1024 and feat(stats): resumable stat units and the StatRun store (rows-first s4) #1026); a request for the old generation gets stats_aborted with no query, and the new generation starts from its first unit.

What changes

  • buckaroo/server/stats_wire.py: STATS_BUDGET_S, run_units, partial_payload, display_args_hash, highlighted_display_args (the overlay's loop, moved here so the final reply can use it), assign_full_stats(dataflow, computed=None), complete_stats finishing a run, _serve_units, handle_stats_request(session, msg, client=None), the bad_request abort, and the digest kept in build_state_message_for.
  • buckaroo/server/stat_run.py: StatRun.elapsed_s and StatRun.errs().
  • buckaroo/dataflow/dataflow.py: _assemble_merged_sd(running_sd=None); the _merged_sd observer calls it.
  • buckaroo/server/websocket_handler.py: passes the handler to handle_stats_request, initializes display_args_hash, and _send_highlight_overlay uses highlighted_display_args.

Tests

Two commits: 2a73c8ed (failing tests) and ad26fdf4 (implementation).

  • test_load_expr.py, TestStatsWire (xorq, over WebSocket): one unit per request and the complete state at the end; the last unit assigns the stats and frees the run; a long budget runs every unit in one request; the columns hint orders units; a stale generation mid-run; client B opening after client A finished; two interleaved clients end with full stats and run no unit twice; the final reply carries the rebuilt df_display_args when the digest differs, carries none when it does not (an int and string table), and keeps the client's highlight; a filtered state ends as the inline session's does; a legacy client connecting mid-run finishes only the units left; a request without the flag finishes a run in progress; a concurrent infinite_request is served between units, with every request under plan 1's 250 ms ceiling (a fake unit that sleeps 40 ms); the first initial_state and the first rows precede every data stat query and rows are served before stats complete; /reload_expr mid-run; the stats.unit, firstpull.stats_total and stats.request spans on the session sink; the completion span's cache outcome (a miss, then a hit after a reload on a cache_storage_path); a unit that raises; a non-boolean flag.
  • test_data_loading_polars.py (pandas and polars): TestRunUnits (units that cost little run together, a request stops once the budget is spent, a unit over the budget is alone in its request, a cold unit after warm ones ends its request, one unit runs whatever the budget, no budget runs every unit, the hint in rewritten names, a raising unit, the spans, elapsed_s, errs()), run on real units with a fake clock and a fake cost; TestPartialPayload and TestAssembledMergedSd (the payload of every fragment equals the dataflow's all_stats, only the covered columns, filtered_* keys for a search, init_sd wins); TestHighlightedDisplayArgs and TestDisplayArgsHash.
  • Three guard tests were added with the implementation because they pass on the earlier code: an incremental request for a cached state runs no unit, an incremental request that cannot plan units takes the whole-run path (and logs why), and a columns hint that is not a list of names is only a hint.
  • Two things in the failing-tests commit were corrected in the implementation commit, both mistakes in the tests: next_unit takes plan order among the preferred columns, not the order of prefer (test_the_columns_hint_is_read_in_the_client_s_rewritten_names assumed the latter), and a decoded all_stats row has a level_0 key that the column sets must skip, as StatsChannel's merge does. One test also dropped a legacy-client check that could not hold (every send to a legacy client completes the session, so with one connected the session is never pending after a push).

On 2a73c8ed (tests only) all nine Python / Test jobs failed (3.11 to 3.14, Max Versions 3.11 to 3.14, Windows), all 28 checks completed. Locally 63 of the new tests fail on the earlier code: 43 in test_data_loading_polars.py, each on a missing attribute, and 20 in TestStatsWire, each on an assertion about the reply (the earlier code ignores incremental and answers with the whole run) or a missing key. CI logs were not read (the REST log API is rate-limited for this account), so the reasons rest on the local run of the same commit. The 174 tests that were in the two files before pass unchanged.

CI on ad26fdf4: all 28 checks completed, 27 succeeded (one of them the Read the Docs status) and deploy was skipped. That includes all nine Python / Test jobs (3.11 to 3.14, Max Versions 3.11 to 3.14, Windows), Python / Lint, Python / Typecheck, the JS job, the wheel build and the Playwright jobs.

Locally, on ad26fdf4, the full unit suite (pytest ./tests/unit -m "not slow") gives 1459 passed and 5 skipped, the 1393 of #1026 plus 66 new tests, and the same 1459 in an environment resolved with the newest versions and pandas 3 (pandas 3.0.6, polars 1.44.2, xorq 0.4.5, numpy 2.5.3). basedpyright on the scoped files reports 0 errors and 0 warnings. Nineteen deliberate regressions of the implementation each make at least one test fail: the budget ignored, the budget checked before the first unit, no catch-up guard, complete_stats ignoring the run, the display config never attached, always attached, the run not freed, the partial payload not restricted to the covered columns, no stats.unit span, a failure that keeps the runs, no flag validation, no cache check before planning, the running sd not used for the raw scope, the final assignment recomputing, the digest not recorded, the columns hint ignored, elapsed_s not summed, a missing units attribute on stats.request, and the wrong tier on a partial reply.

Measurements

Each incremental request timed through handle_stats_request (no WebSocket), from a schema-tier session to the final reply, on the 27-column synthetic parquet fixture of #1026 (4 ints, 6 floats with nulls, 2 low-cardinality ints, 8 strings of 5 to 5,000 distinct values, 3 booleans, 4 timestamps), the default 75 ms budget, an Apple M4 Pro with other agents running. The final request includes its own units, the final assignment and the snapshot refresh.

Fixture Batch Requests Median non-final request Longest non-final request Final request Sum
52,814 rows one batch 4 0.087 s 0.087 s 0.084 s 0.341 s
52,814 rows stat_chunk_cells=1M 5 0.079 s 0.095 s 0.038 s 0.369 s
2,000,000 rows one batch 10 0.090 s 0.860 s (the batch) 0.055 s 1.636 s
2,000,000 rows stat_chunk_cells=12M (6-column chunks) 13 0.103-0.105 s 0.252-0.253 s (two runs) 0.029-0.031 s 1.636 s

A request is as long as its longest unit plus the partial payload, so the bound a waiting infinite_request sees is the unit. With the batch whole at 2M rows that is 0.86 s; with 6-column chunks it is 0.25 s, at plan 1's proposed 250 ms ceiling and a few milliseconds over it. The final request's own work (the assignment and the cascade it fires) is small here, 30 to 80 ms with its units; a state with a cleaning op or a search computes more in it (see "Not in this PR").

Why default behaviour is unchanged

  • Everything new is opt-in. Only a client that sends ?caps=stats_update to a session loaded with stats_delivery="deferred" can send a stats_request that matters, and only incremental: true runs units for a budget. An inline session is never pending, so none of this runs for it. Splitting the merged_sd observer into _assemble_merged_sd changes no value: the observer calls it with no running sd, and every existing test of merged_sd passes unchanged.
  • A request without the flag answers as in feat(server): stats_request, stats_update and df_meta.stats on deferred sessions (rows-first s3) #1024: stats_update {type, stats_gen, scope, tier, final: true, payload, elapsed_ms}, with two additive fields, remaining: 0 and, for a client whose pending frame carried a different display config, df_display_args. A client that does not read them ignores them.
  • _send_highlight_overlay builds the same message from the same loop, which now lives in highlighted_display_args.
  • No existing test changed. The edits to the test files are added tests and the corrections named above to tests added in this PR.

Deviations from the plan

  • The plan has every stats_request run units for 50 to 100 ms. Here only incremental: true does, because the whole-run request exists in feat(server): stats_request, stats_update and df_meta.stats on deferred sessions (rows-first s3) #1024 with its own tests and clients, and the maintainer's rule is that default behaviour does not change. The unflagged request is the whole run, as the phase asks ("the whole-run request becomes run all units"): it runs every unit still to run.
  • With no StatRun the whole run is one _get_summary_sd call (the same units in the same order, shown by feat(stats): resumable stat units and the StatRun store (rows-first s4) #1026) instead of a loop over a run's units. _get_summary_sd is the hook the xorq dataflow overrides (the pandas error-frame result, the debug-mode raise), and the feat(server): stats_request, stats_update and df_meta.stats on deferred sessions (rows-first s3) #1024 tests patch it. With a run that has progress it runs the units left.
  • The final reply carries the complete all_stats, not only what lies beyond the client's cursor. The final assignment also fills layers that no fragment carries (the other scopes' cleaned_* and filtered_* keys, an init_sd or processing sd override), and a client that missed a frame then still ends complete.
  • The budget is a server constant (STATS_BUDGET_S = 75 ms). The plan's 250 ms ceiling is a bound on the longest request, and a request cannot be cut short inside a unit, so the ceiling is what a test asserts, not something the server enforces.
  • columns are the grid's rewritten names, and the first unit that covers any of them runs first (plan order among them, as StatRun.next_unit does), not in the order the client lists them.
  • stats_aborted gains the reason bad_request.
  • firstpull.stats_total is not emitted as firstpull.summary_stats. That span's duration is the time spent computing stats, which a run spread over several requests has no single value for. The attributes are the same and sit on firstpull.stats_total, whose duration is the request that completed the run, with run_secs for the sum of the units.
  • The /load_expr snapshot copy (the third site the plan names) is not replaced by refresh_session_snapshot. The helper applies the session's stored component_config, and that copy applies only the one in the POST, so replacing it would change what a re-POST that omits the field sends. feat(server): stats_request, stats_update and df_meta.stats on deferred sessions (rows-first s3) #1024 left it for the same reason.
  • Counters for updates sent, requests dropped as stale and errors are the outcome attribute of stats.request, as in feat(server): stats_request, stats_update and df_meta.stats on deferred sessions (rows-first s3) #1024.

Not in this PR

  • Making deferred the default, the filtered-count memoization and the sort guards (plan 3 phase 7), the scalar and policy tiers, client code, a child process.
  • A state with a cleaning op or a search filter still computes its raw and clean scopes whole inside the final assignment, because assigning summary_sd fills the other scopes' cache entries through the cascade. For xorq that is a full stats pass per scope in the request that finishes the run. Units for those scopes belong with plan 3's scoped runs.
  • A measurement of the final assignment's own cost (the cascade it triggers) on a wide table; it is one request, but the figure is not taken here.

Stack

#1021, #1022, #1024 and #1026 are underneath; their commits are in this diff until they merge. Commits of this phase: 2a73c8ed (failing tests) and ad26fdf4 (implementation).

🤖 Generated with Claude Code

paddymul and others added 17 commits October 3, 2026 22:51
…the _handle_widget_change split (rows-first s1)

A default-tier XorqBuckarooWidget and BuckarooWidget publish df_data_dict,
then df_display_args, then the rest of the widget_args_tuple observers on a
search change, and merged_sd carries the full stat set. These pass on main
and pin the behaviour the split must keep.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…-handler fields (rows-first s1)

A schema-tier XorqServerDataflow should match full stats on pinned_rows,
data_key, summary_stats_key and (except the stats-derived minWidth)
column_config, issue no data query besides the cached count, and keep
init_sd hints and sorted windows working. A pending state must write
nothing under a full-tier cache key, and a later full assignment must reach
merged_sd for the raw, clean and filt scopes. assemble_merged_sd must equal
merged_sd, and _handle_widget_change must be built from separately callable
all_stats and display-args builders. /load_expr and /reload_expr accept
stats_tier and stats_delivery, replay them on reload, and keep them out of
the warm short-circuit's has_config tuple.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… s1)

Add a dataflow-level stats_tier ("full" default, "schema"). The xorq schema
tier builds identity and typing for every column from the expression's
schema, with no data query beyond the cached row count, so a dataflow
constructs in milliseconds rather than the stats' hundreds.

The tier is part of _scope_cache_key, so a schema entry is never read as a
full one, and _populate_sd_cache stores summary_sd under the filt key only
if it was computed for the current frame, klass list and tier. add_analysis
no longer builds DFStatsClass outside the hook when the tier is not full.
The merged_sd observer body is extracted as the pure assemble_merged_sd,
and _handle_widget_change is split into _build_df_data_dict and
_build_df_display_args.

/load_expr and /reload_expr accept stats_tier and stats_delivery, stored on
the session beside dataflow_kwargs and replayed on reload. They stay out of
the has_config tuple; the warm short-circuit compares the stored pair.
stats_delivery="deferred" builds the schema-tier dataflow and publishes it.
Defaults (full, inline) leave behaviour unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ation test (rows-first s1)

The Max Versions jobs resolve pandas 3, which reports a string column's
dtype as 'str' where pandas 2 says 'object'. The characterization test
asserted 'object'. Verified in a Max Versions environment (pandas 3.0.6,
polars 1.44.2, xorq 0.4.5): the unit suite passes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…g on a skipped column (rows-first s1)

A column in skip_stat_columns gets only name, dtype and length from the
full-tier pipeline, so its _type comes from init_sd. The schema tier layers
the schema-derived _type and is_* keys over it, so an int64 column that
init_sd types as float merges as integer and renders with zero fraction
digits instead of the float displayer init_sd asked for.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ows-first s1)

_get_schema_sd never read skip_stat_columns, so a skipped column's
schema-derived _type and is_* keys overrode init_sd's _type once merged. The
full tier gives a skipped column only name, dtype and length. The schema tier
now does the same, so init_sd's _type decides the displayer at both tiers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…er (rows-first s2)

ServerDataflow and PolarsServerDataflow with stats_tier="schema" should publish
the display state full stats give (column_config without stats-derived keys,
pinned_rows including a host-supplied one, data_key, summary_stats_key) with no
stat computed on the data, still apply init_sd, serve sorted windows, take a
later full assignment into merged_sd for every scope, and assemble to the same
sd as merged_sd. Both backends run through the same parametrized class, plus a
pandas test that pins how an object column is typed from its dtype.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ServerDataflow and PolarsServerDataflow now implement the _get_schema_sd hook,
so stats_tier="schema" builds a dataflow that types every column from its dtype
and runs no stat on the data. schema_sd (stat_pipeline.py) builds the sd the way
process_df shapes it: an empty frame gives {}, a skipped column keeps only its
names. pandas applies the existing typing_stats to a zero-row slice and derives
_type through the _type stat; polars factors pl_dtype_typing out of
pl_typing_stats and feeds it the dtype.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…tats_request (rows-first s3)

On a deferred /load_expr session a stats_request {stats_gen, scope} should
return a stats_update with the matching stats_gen whose inline wide payload
equals the all_stats an inline session sends, a stale stats_gen should get
stats_aborted and run no query, and /load_expr and /reload_expr should bump the
generation. df_meta.stats should be injected on every frame and survive a
dataflow-field change, which returns the session to the schema tier. With a
caps client and a legacy client on one session, the legacy client should keep
getting complete messages through the websocket broadcast, the /load_expr,
/reload_expr, /load and /load_compare pushes and the highlight overlay, while
the caps client gets a stats-free frame and then pulls a stats_update. A spy
telemetry sink should see a stats.request span.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…eration edge cases (rows-first s3)

Four more cases for the stats wire format, kept in their own commit so each is
seen failing on CI before the implementation lands. Returning to a state whose
stats were completed once is answered from summary_stats_cache with no query.
Completing the stats keeps the session's component_config on the refreshed
display config. A warm /load_expr, which rebuilds nothing, leaves stats_gen
alone. A stats_request on a session with no data is answered with
stats_aborted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ed sessions (rows-first s3)

A client that advertises ?caps=stats_update gets a stats-free initial_state on a
deferred /load_expr session (df_meta.stats.status "pending") and pulls the stats
with stats_request {stats_gen, scope}. The reply is a stats_update carrying the
dataflow's all_stats as an inline wide envelope, or stats_aborted when the
generation is stale. The request is the whole run: one synchronous call that
computes the full stats, writes the full-tier summary_stats_cache entry, assigns
summary_sd and refreshes the session snapshot through one helper.

stats_gen is a server-owned counter bumped by every load handler and by a state
change that touches a dataflow field, which also returns a deferred session to
the schema tier. df_meta.stats is injected by build_state_message from the
session, since the dataflow rebuilds df_meta wholesale. Every send site goes
through build_state_message_for, so a client without the capability still gets
complete messages (its missing stats run synchronously first); broadcast_state
replaces the five copies of the send loop and sends to capable clients first.
The stats_request branch binds the session's telemetry sink and emits a
stats.request span.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s (rows-first s4)

Both pass on the current code. process_df is written out as the
process_column loop it is, and each xorq histogram query's snapshot-cache
key is compared with the key of a query built in the test, so the refactor
into resumable units that follows can be checked against something that does
not go through the units.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…tore (rows-first s4)

Each stats class gets plan(state) and run(unit, acc): the fragments of the
planned units, assembled through assemble_merged_sd, must equal the full-stats
merged_sd on pandas, polars and xorq with init_sd, cleaning and overrides; a
column group returns only its columns; skip_stat_columns columns get no unit;
the xorq batch can be split by column chunk behind a default-off flag and is
refused for anything but a plain parquet scan; the histogram cache keys stay
put. A StatRun held on the session, keyed by (stats_gen, scope), keeps an
append-only fragment list and accumulator, runs a unit only when asked, is
dropped when stats_gen changes, and each connection keeps its own cursor.

test_the_batch_is_one_query_by_default passes on the current code: it pins the
default the flag must not change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
StatPipeline and XorqStatPipeline get plan(state), new_accumulator(state) and
run(unit, acc); process_df and process_table are those three in order, so the
inline path and a caller running units one at a time compute the same stats.
pandas and polars plan one unit per non-skipped column. xorq plans the scalar
batch (which also yields histogram_bins), then one histogram query per column,
visible columns first. The batch can be cut into column chunks sized by cells
when a host sets stat_chunk_cells, and only for a plain parquet scan: any other
source falls back to the single batch and logs why.

A StatRun, kept on the session by (stats_gen, scope), holds the planned units,
an append-only fragment list and the accumulator. It has no thread, timer or
callback, begin_stats_generation drops it, and each WebSocket connection keeps
its own StatCursor into the list. CustomizableDataflow.build_stats is the one
place a stats class is built, with run=False for plan/run callers.

Two of the new tests are corrected here: the default-path test now ignores the
PERVERSE_DF self-check units, and the xorq comparisons round floats to nine
digits because a parallel aggregate adds its partial sums in varying order.
The new-module imports in the tests move to module level.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… once (rows-first s4)

A column group, the priority columns and StatRun's prefer hint accept
original or rewritten (a, b, c) names and read each name in both. When an
original name equals another column's rewritten name, a group returns the
columns of both, priority ranks both first, and prefer picks the unit of
whichever comes first in the frame.

On a frame whose columns are c, b, a (rewritten a, b, c):

- StatState(df, columns=('c',)) plans the units of c and a.
- priority=('a',) plans c before a.
- StatRun.next_unit(prefer=('a',)) picks the unit of c.

The new tests also pin the namespace argument the fix adds, so a client that
holds the rewritten names can ask for exactly those: StatState(namespace=)
and StatRun.next_unit/run_next(namespace=), with "any" (the default),
"original" and "rewritten".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rst s4)

A column group, StatState.priority and StatRun's prefer hint matched a name
against both the original and the rewritten (a, b, c) column names. When an
original name equals another column's rewritten name, one name picked two
columns: a group returned columns that were not asked for, and prefer ran the
unit of whichever column came first in the frame.

resolve_names reads each name once. In "any", the default and what the code
did for names that do not collide, 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. StatState(namespace=) and StatRun.next_unit/run_next(namespace=) also
take "original" and "rewritten", so a caller that holds the client's
rewritten names says so and gets exactly those columns. Priority and prefer
names are resolved against every column of the frame, not only the columns
in the group, so a name picks the same column whatever group is asked for.

prioritized now takes the state, since it needs the frame's columns and the
namespace to read the priority names.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t cursors and the final assignment (rows-first s5)

Covers the request branch that runs units for a time budget (at least one, one
cold unit per budget), the per-handler cursors over a shared StatRun, the
final assignment that frees the run and carries the rebuilt df_display_args,
a legacy client finishing a run in progress, a concurrent infinite_request
between units, the end-to-end order of first initial_state, rows and stats,
/reload_expr mid-run, and the stats.unit and firstpull.stats_total spans.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

📦 TestPyPI package published

pip install --index-strategy unsafe-best-match --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ buckaroo==0.15.9.dev37188764309

or with uv:

uv pip install --index-strategy unsafe-best-match --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ buckaroo==0.15.9.dev37188764309

MCP server for Claude Code

claude mcp add buckaroo-table -- uvx --from "buckaroo[mcp]==0.15.9.dev37188764309" --index-strategy unsafe-best-match --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ buckaroo-table

📖 Docs preview

🎨 Storybook preview

…nt cursors and the final assignment (rows-first s5)

A stats_request with incremental: true runs the units of the session's StatRun
for about STATS_BUDGET_S (at least one) and answers with the fragments the
asking client has not seen, read through the handler's own StatCursor. The
request that runs the last unit does the final assignment in complete_stats:
the full-tier summary_stats_cache entry, summary_sd, the session snapshot and
the status in one step, the run freed, and the final reply carries the
rebuilt df_display_args when its digest differs from the one the client holds.
Without the field a request is still the whole run, now finishing the run's
remaining units when a client has made progress. A legacy client's complete
frame does the same.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@paddymul

paddymul commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

Closing. This is the client-pull loop (incremental stats_request, a 75 ms budget per request, per-connection cursors) that the revised D2 of ADR-002 (#1043, 7943412) replaces with a server push after the first row reply.

One piece carries over: attaching the rebuilt df_display_args to the final reply when its hash changed (display_args_hash, highlighted_display_args), which the revised D5 still calls for. The branch is kept for that.

@paddymul paddymul closed this Oct 6, 2026
paddymul added a commit that referenced this pull request Oct 6, 2026
…d the compare reset under a stats policy (rows-first p33)

Cases found untested after the first tests commit: a scalar tier named with
inline delivery builds a schema dataflow, and /load_compare clears the policy
a session held.

Rebased off the closed unit PRs (#1026, #1028): the assertions on unit tiers
and on df_display_args in the final reply are dropped with the code they
tested.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
paddymul added a commit that referenced this pull request Oct 6, 2026
…irst p33)

/load_expr and /reload_expr accept stats_tier auto, full, scalar or schema,
stored with the pair and kept out of has_config, so the warm short-circuit
holds. When the dataflow is built at the schema tier the handler resolves the
policy (resolve_stats_policy) from the count it already has, stores it on the
session and starts the stats generation from it: a target below full is
not_computed with the policy's reason.

df_meta.stats reports the policy as tier_target and estimate, plus
auto_request, requestable, omitted_keys, approx_keys and demand_columns where
they differ from their documented defaults. It is applied at WebSocket open
only for a client that sends ?caps=stats_update,stats_ondemand. A client with
stats_update only is told the session is pending and pulls the stats, and a
client with no caps gets them at connect, as for any deferred session. A tier
the host named (scalar, schema) reaches every client as before. A session on
an explicit stats_tier full within the ceiling sends the message it always has.

/load keeps resolving to full: it does not read the field and clears a policy
left on the session.

Rebased onto #1024 without the unit PRs (#1026, #1028): stats_request is the
whole run of #1024, so handle_stats_request now takes the client to decide
stats_to_pull, and the incremental and display-config parts are gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
testpypi — ad26fdf4 Deployed Oct 4, 2026 by paddymul via Publish to TestPyPI #1694
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant