Skip to content

feat(client): scheduler and control request incremental stats with a columns hint (rows-first c4b) - #1030

Closed
paddymul wants to merge 12 commits into
adr-002-rows-first-stats-deliveryfrom
feat/rowsfirst-c4b-client-incremental-requests
Closed

paddymul wants to merge 12 commits into
adr-002-rows-first-stats-deliveryfrom
feat/rowsfirst-c4b-client-incremental-requests

Conversation

@paddymul

@paddymul paddymul commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The server phases s4 and s5 (#1026, #1028) added stat units and an opt-in incremental: true on stats_request: such a request runs units for about 75 ms and answers stats_update {final: false, remaining, payload} with the new fragments, the final reply carries the complete all_stats, and a columns hint (the grid's rewritten column names) runs the units that cover those columns first. The client scheduler of c4 (#1027) was written in parallel and still sends whole-run requests: a request with no incremental field runs every unit in one synchronous call, however long the longest unit is, and the hint is never sent. The Compute summary stats control sends a whole-run request too.

Phase and plan references

Client phase c4b, the follow-up to c4. Plan 1 (plans/01-rows-first-stats-separate-plumbing.md) section 9 "Phase 4" and section 5 (Strategy C), with section 3 and section 4.0. Plan 2 (plans/02-rows-first-xorq-and-lazy-polars.md) section 3 and section 4.1 ("Request"). Message names and shapes follow the server branches #1024 and #1028.

Approach

Request shape. requestStats in StateOrchestrator.ts is the one place that builds a stats_request, and the scheduler and the control both use it. It now sends {type, stats_gen, scope: "raw", incremental: true}, plus columns when the grid has reported the columns it shows (an empty or missing list sends no hint), plus force: true for the control. The hint is read from the model's visible_columns key at the moment a request goes out, so each request of a run carries the columns on screen then.

The run. The scheduler's chain was already one request per reply that leaves the status pending, read off the model (a new df_data_dict under the same df_meta). It needed no new logic for partial replies: c2's StatsChannel merges a non-final stats_update into all_stats and leaves df_meta alone, so the status stays pending and the placeholder pinned rows stay until the final reply, which carries the complete all_stats and sets the status to complete. A gen change (a local dataflow-field change or a frame with a new gen) cancels the run and starts a new one after the delay, and StatsChannel drops a late reply for the old gen. A server that does not know the fields ignores them, runs the whole run and answers final: true, which ends the run the same way. Tests pin each of these.

Where the hint comes from. DFViewerInfinite takes on_visible_columns and calls it with the data columns that have a header cell in the grid's viewport, when the grid is ready and on onVirtualColumnsChanged, skipping an unchanged list. BuckarooInfiniteWidget forwards the prop. BuckarooView and the standalone BuckarooApp pass a callback that stores the list on the model with setVisibleColumns(model, columns) (new, exported next to requestStats). The key is client-side: the server never sends it, and WebSocketModel keeps it across initial_state frames.

The columns are read from the grid's rendered header cells (.ag-header-viewport .ag-header-cell[col-id], one frame after the event), not from api.getAllDisplayedVirtualColumns. That function lives in AG Grid's ColumnApiModule, which this package does not register. On main api.getColumn (the active-cell refresh in onCellClicked) and api.getColumnState and applyColumnState (the per-view column state in the view_name effect) are already inert for the same reason: AG Grid logs error #200 for each. Registering the module would switch them on in every grid, which changes default behaviour, so this PR does not. I did not touch those call sites.

What changes

  • src/server/StateOrchestrator.ts: requestStats sends incremental: true and the hint; VISIBLE_COLUMNS_KEY and setVisibleColumns; the header comment describes the run.
  • src/components/DFViewerParts/DFViewerInfinite.tsx: on_visible_columns (the header-cell read, onGridReady and onVirtualColumnsChanged).
  • src/components/BuckarooWidgetInfinite.tsx: forwards on_visible_columns in the buckaroo widget (viewer mode has no stats).
  • src/server/BuckarooView.tsx, packages/js/standalone.tsx: the callback. src/index.ts: exports setVisibleColumns (named and on the default srt object).

Tests

All new tests are in existing files of the same kind. The tests-only commit is d7eda002, pushed and watched on CI before the fix. A second tests-only commit (5d1e0467) adds a two-frame wait to the wide-table test, which failed once in a full local run: it released the reply as soon as the header cells had changed, a frame before the page reports its columns. No assertion changed. The fix is e5649605.

CI on d7eda002: all 28 entries in the rollup completed and exactly three failed, JS / Build + Test, Storybook Playwright Tests and Server Playwright Tests; every Python / Test job passed (this phase has no Python change). CI logs were not read (REST rate limit); the reasons are from running the same files locally against the stubs of that commit, where 44 jest tests failed on assertions, five of the new server Playwright tests failed and the Storybook control test failed.

  • src/server/StateOrchestrator.test.ts: a block "incremental requests" (the shape with no hint, with a hint, with an empty list, the hint re-read for each request of a run, the same shape for the first request of the next gen); requestStats with the hint and force; in the block that builds a real WebSocketModel from a fake socket: partial replies walked to the final one with df_meta unchanged (the same object) until the last, a whole-run final from a server that ignores the field, a gen change mid-run (late reply for the old gen dropped, one request for the new gen, which then chains).
  • src/components/DFViewerParts/DFViewerInfinite.test.tsx: the grid reports the data columns of its viewport (not the index column), again when they change, not when they stay the same, and [] before layout. The AG Grid mock now renders header cells for the columns it is told are in view. BuckarooInfiniteWidget.flash.test.tsx: the widget hands the grid the callback. BuckarooView.stats.test.tsx: the callback stores the list and the control's request carries it.
  • pw-tests/server.spec.ts (server Playwright, "WebSocket data flow"): the real standalone page against a real session whose first frame is rewritten to pending, with a harness answering stats_request (the real server's own all_stats as the final payload, so no xorq is needed): several partial replies lead to repeated requests and the status stays pending until the final; a gen change mid-run stops the run for the old gen and starts one for the new gen; a 60-column table sends only the columns on screen, and after a horizontal scroll the next request carries the new ones.
  • pw-tests/stats-scheduler-states.spec.ts (Storybook): the control's request carries incremental, force and the columns the grid reports; a stat row that has arrived for one column leaves the other columns' cells empty while the status is pending. The second one passes on the earlier code, since AG Grid does not render a cell whose value is absent. It is a characterization test of what a partial merge shows.

Existing tests changed, and why. The scheduler's request() helper in StateOrchestrator.test.ts gained incremental: true, and the existing assertions on the sent message in BuckarooView.stats.test.tsx, server.spec.ts (two) and stats-scheduler-states.spec.ts (one) gained incremental: true and the hint where the grid reports one: the request shape changes by design, and every one of those asserts the exact message. No assertion about behaviour was loosened.

Two on_visible_columns props are unused stubs in the tests-only commit so that the new tests compile and fail on assertions.

Run locally on the fix: jest 431 passed (417 before this phase); tsc -b; the five Storybook Playwright files, 16 passed; the server Playwright files, 42 passed in server.spec.ts on three runs in a row and 17 in the other four files on three runs in a row (in one earlier full run server-buckaroo-search.spec.ts failed once, on the race that c4's description names, and did not fail in those three runs); tests/unit 1185 passed, 5 skipped. Twelve deliberate regressions of the implementation (no incremental, no hint, an empty hint sent, another model key, the index column not filtered, no dedupe, no onVirtualColumnsChanged, no report on grid ready, the widget, BuckarooView and the control each dropping their hop, the callback a no-op) each make at least one jest test fail, and dropping the callback from standalone.tsx makes five server Playwright tests fail.

I also ran the built standalone page against a scratch checkout of #1028 (detached at ad26fdf4) on a free port, with a real deferred /load_expr session over a 200,000-row, 30-column xorq memtable. The observed sequence: initial_state (pending, gen 1), infinite_request, infinite_resp, then stats_request {gen 1, incremental: true, columns: a-g} 15 ms later; 16 partial stats_update replies with remaining 30, 28, ... 2, each answered at once by the next stats_request (the hint widening to a-j after the grid laid out); the 17th reply final: true, remaining: 0 (carrying df_display_args, which the client does not apply), and the status bar reading "Summary stats ready". The whole run took about 2.0 s, in requests of 120 to 200 ms. In a second run a search term was typed 700 ms in: the next reply for gen 1 arrived after the buckaroo_state_change and changed nothing, the server sent initial_state pending gen 2, one stats_request for gen 2 went out 315 ms after that frame, and the run chained to a final reply. No request for gen 1 followed the change. That check is not committed because it needs xorq and the other branch's server.

Why default behaviour is unchanged

Nothing is requested unless df_meta.stats says pending, which only a deferred /load_expr session reports, so a default session still sends no stats_request (the existing server Playwright test asserts it). The hint is reported only when a host passes on_visible_columns; the Jupyter widget and viewer mode do not, so their grids add nothing (the one new prop on DFViewerInfinite is optional and, when absent, the read is skipped). The grid registers no new AG Grid module. A server that predates incremental ignores the field.

Deviations from the plan

Not in this PR

  • Policy fields (auto_request, requestable, demand_columns, ceiling) and the tier-aware control: the next client phase.
  • Any server change. A buckaroo-js-core release and a tallyman bump.

Stack

Built on feat/rowsfirst-c4-client-stats-scheduler (#1027), which contains c2 (#1025) and c0a (#1020). The PR targets main, so the diff includes their commits until they merge: c0a 29e1e42c and 405f4ded; c2 fe046acc, 3118435a, 1fd9cff8 and 5935432d; c4 dd6ebe6a, d6afbf98 and 95dcceec. This phase's commits are d7eda002 and 5d1e0467 (tests) and e5649605 (fix). Read only those. The server branches #1024 and #1028 are not part of this PR.

🤖 Generated with Claude Code

paddymul and others added 10 commits October 3, 2026 22:58
…protocol (rows-first c0a)

Tests for the client half of plan 1 phase 0a. A server that sends the
summary stats separately, or not at all, leaves the first message
without values the client has been assuming.

BuckarooView: a change that reached the model before the effect
subscribed, one initial_state with metadata decoding once, and
out-of-order decodes applying the newer.

Pinned rows: a valueless key shows a placeholder with its own row id
while df_meta.stats.status is pending, and is omitted when not_computed.
The simple tooltip returns nothing for a valueless cell. color_map is
silent without bins and restyles when they arrive. An unrelated
df_data_dict update keeps the in-flight indicator when the server
reports df_meta.stats.

One Storybook Playwright test covers pending, not_computed and complete
in a real browser, and is added to the Storybook list in
scripts/test_playwright_storybook.sh.

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

BuckarooView reads model.get() for every key once its listeners are in
place, so a change:* emitted before the effect ran reaches React. All
df_data_dict values go through one decoder (makeLatestDictDecoder) that
skips a dict it has already seen and applies only the newest decode, so
an initial_state with metadata decodes once and out-of-order decodes
cannot overwrite a newer one. standalone.tsx gets the same decoder and
catch-up.

df_meta.stats.status reaches the grid as stats_status. While pending, a
required pinned key with no value becomes a placeholder row that carries
its key as index (unique row id, empty value cells). When not_computed
it is omitted. A missing df_meta.stats, or any other status, keeps the
old behaviour.

The simple tooltip returns nothing for a cell with no value or no row
data. color_map no longer logs when bins are missing, and the grid
refreshes the color-mapped columns when their bins change after the first
render. That needs RenderApiModule, which was not registered, so
api.refreshCells logged AG Grid error 200 and did nothing.

BuckarooInfiniteWidget keeps inFlight when only df_data_dict changed and
the server reports df_meta.stats; the answer to the dispatched change is
a frame with a new df_meta. Without df_meta.stats the rule is unchanged.

The Playwright story test no longer hovers a valueless pinned cell: with
the old tooltip it still passed, since AG Grid does not call the tooltip
for an empty value.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Jest tests, driven through a WebSocketModel with a fake socket, for the
client half of the stats wire: a stats_update is key-merged into all_stats
and dropped when its stats_gen is not the expected one, the expected gen
follows df_meta.stats.gen on every applied initial_state, a merge in
flight is discarded or redone when a frame replaces the dict, and
stats_aborted moves the status. Plus withStatsCapability and
BuckarooServerView putting ?caps=stats_update on the WebSocket URL, and a
server Playwright test that the standalone page does the same.

StatsChannel.ts is a stub (identity withStatsCapability) so the tests fail
on assertions rather than on a missing module.

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

Three more cases for the stats_update merge, found untested after the
first test commit: a wide parquet_b64 payload as the server sends it (the
shared summary_stats fixture, not a json envelope), a model that holds no
df_data_dict yet, and a dict with no all_stats key.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Two stats_update messages for one gen are applied in arrival order even
when the first payload decodes more slowly: the final update merges last
and the status completes only after both merges.

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

StatsChannel is the client half of the stats wire. WebSocketModel hands it
every text frame first. A stats_update for the gen the model's df_meta
reports is decoded and key-merged into df_data_dict.all_stats (new row
objects, a new dict), updates apply one at a time in arrival order, and a
final update sets df_meta.stats to complete at the update's tier. A merge
is dropped when the gen moves on while it decodes and redone when a frame
replaces the dict. stats_aborted for the expected gen sets error or
not_computed. Other message types are ignored as before.

withStatsCapability adds caps=stats_update to a WebSocket URL; both wiring
copies (BuckarooServerView and the standalone page) use it so the server
treats them as capable clients.

The guard tests that already pass before the fix (caps already present, no
df_meta.stats, ignored aborts, unknown types, infinite_resp pairing) are
added here, with a makeModel(null) helper so a model without stats can be
built.

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

Jest tests for the scheduler (StateOrchestrator rewritten around an IModel:
a stats_request after the first infinite_resp, one request per reply until
final, nothing after a state change, nothing for a search_string-only change),
for the status bar's stats column and Compute summary stats control, for the
widget and BuckarooView wiring of that control, and for pinned rows in the
error state. Playwright tests on Storybook cover the four states without
layout shift and the control's request, and the server spec runs the real
standalone page against a session whose first frame says the stats are pending.

StateOrchestrator.ts, StatusBar.tsx and BuckarooWidgetInfinite.tsx carry only
the signatures the tests use, so the tests fail on assertions.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…tus layout and the control wiring (rows-first c4)

Cases found untested or mis-specified after the first push, written against
the same stubs: a model that starts complete waits out the delay for its first
state change, as does a change made after the earlier stats completed; a
request's time is measured once; the Compute summary stats button calls its
handler with no arguments; the standalone page offers that button for a session
whose stats are not computed and asks for nothing until it is clicked. The
layout spec now says what holds across statuses (nothing above or beside the
grid moves, and the grid's height follows the pinned area), and the story loads
the widget's stylesheet as the real page does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…g, not computed and error states (rows-first c4)

StateOrchestrator becomes the client scheduler for the stats wire. It takes an
IModel, so it sends stats_request {stats_gen, scope: "raw"} through model.send
and watches change:df_meta, change:df_data_dict, change:buckaroo_state and
msg:custom. It asks once the first infinite_resp has arrived (or after 1.5 s
without one), asks again for each reply that leaves df_meta.stats pending, and
stops when the status changes. A change to post_processing, cleaning_method or
quick_command_args waits out a delay of twice the last request's time (200 to
3000 ms) before asking for the next gen; a search_string-only change, or any
other field, leaves the schedule alone. Nothing is requested unless
df_meta.stats says pending.

WebSocketModel starts the scheduler, so BuckarooServerView and the standalone
page get it with no wiring of their own. The status bar gets a fixed-width stats
column, for sessions that report df_meta.stats, showing loading, a Compute
summary stats button that sends stats_request {force: true}, the error reason, or
ready. Valueless pinned rows are omitted in the error state, as in not_computed.
requestStats and StateOrchestrator are exported for hosts with their own IModel.

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

The scheduler and the Compute summary stats control should send
stats_request with incremental: true and, when the grid has reported them,
the columns it shows as the columns hint. A run is one request per partial
reply, with the stats pending until the final reply, and a gen change
mid-run stops it and starts one for the new gen.

Jest: the scheduler's request shape, hint and chain against a fake model and
against a real WebSocketModel (partial replies, a final that carries the
complete all_stats, a whole-run final from a server that ignores the field,
a gen change mid-run), requestStats, BuckarooView's callbacks, and the grid
reporting its viewport columns. Playwright: the server spec (a harness
answers stats_request over a real session, including a wide table whose hint
follows a horizontal scroll) and the Storybook control. The existing request
assertions gain incremental: true.

The two unused on_visible_columns props are stubs so the tests compile.

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.dev37193367183

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.dev37193367183

MCP server for Claude Code

claude mcp add buckaroo-table -- uvx --from "buckaroo[mcp]==0.15.9.dev37193367183" --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

paddymul and others added 2 commits October 4, 2026 05:48
… test releases a reply (rows-first c4b)

The grid reports the columns it shows a frame after it renders them, and
the wide-table test released the reply as soon as the header cells had
changed, so on a loaded machine the next request could go out with the
earlier hint. Wait two animation frames first. No assertion changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ith a columns hint (rows-first c4b)

requestStats, which the scheduler and the Compute summary stats control both
use, now sends stats_request with incremental: true and, when the grid has
reported them, the columns it shows as the columns hint, read from the
model's visible_columns key when the request goes out. A server on the unit
path answers with a partial stats_update per request, the scheduler asks
again for each, StatsChannel merges them without leaving "pending", and the
final reply with the complete all_stats ends the run. A server that ignores
the fields answers with a whole-run final, which ends it the same way.

DFViewerInfinite takes on_visible_columns and reports the data columns that
have a header cell in the grid's viewport on grid ready and on
onVirtualColumnsChanged. BuckarooInfiniteWidget forwards it, and BuckarooView
and the standalone page store the list on the model with setVisibleColumns.
The columns are read from the rendered header cells because the grid's column
API is not registered, and registering it would switch on the column-state
calls that are inert today.

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

paddymul commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

Closing. Incremental requests and the visible-columns hint drive the client-pull loop that the revised D2 of ADR-002 (#1043, 7943412) replaces with a server push after the first row reply. Nothing here carries over.

@paddymul paddymul closed this Oct 6, 2026

This branch was successfully deployed

1 active deployment
testpypi — e5649605 Deployed Oct 4, 2026 by paddymul via Publish to TestPyPI #1699
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