Skip to content

feat(client): merge stats_update into all_stats and advertise the capability (rows-first c2) - #1025

Merged
paddymul merged 8 commits into
adr-002-rows-first-stats-deliveryfrom
feat/rowsfirst-c2-client-stats-channel
Oct 6, 2026
Merged

paddymul merged 8 commits into
adr-002-rows-first-stats-deliveryfrom
feat/rowsfirst-c2-client-stats-channel

Conversation

@paddymul

@paddymul paddymul commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The server side of the stats wire is on its own branch (#1024): a client that connects with ?caps=stats_update to a deferred session gets a first initial_state without data-derived stats (df_meta.stats.status == "pending"), asks for them with stats_request {stats_gen, scope}, and receives a stats_update {stats_gen, scope, tier, final, payload, elapsed_ms} or a stats_aborted. No client handles any of that yet:

  1. WebSocketModel drops unknown message types, so a stats_update is lost.
  2. Nothing tells the client which stats generation it is looking at, so a stats_update for a state it has left (a /load, a dataflow-field change) could not be told from a current one.
  3. The standalone page and BuckarooServerView open their WebSocket without ?caps=, so the server treats them as legacy clients and computes the stats synchronously before every message built for them.

Phase and plan references

Plan 1 (plans/01-rows-first-stats-separate-plumbing.md) section 9 "Phase 2" (the jest half: WebSocketModel merges stats_update, drops a mismatched stats_gen, advances expectedGen on a broadcast frame with no reply_seq, ignores unknown types) and section 4.0 (client). Plan 2 sections 3 and 4.1 for the request and reply shapes; plan 3 section 3.2 for elapsed_ms. Message names and shapes follow the server branch feat/rowsfirst-s3-stats-wire-server (#1024), read from its stats_wire.py and checked against frames captured from that branch (see Tests).

Approach

A new module, src/server/StatsChannel.ts, holds the client half. WebSocketModel gets one field and one line in onmessage: it constructs new StatsChannel(this) and returns early when stats.handle(msg) consumes a frame. Nothing else in WebSocketModel.ts changes, so the rewrites in #1013, #1014 and #1015 conflict with it as little as possible.

  • Expected gen. StatsChannel.expectedGen is the df_meta.stats.gen of the df_meta the model currently holds, read each time. It starts from the frame the model was built from, and every initial_state the model applies moves it, a broadcast frame with no reply_seq included. Reading the model rather than the initial_state branch means it follows the frames that were actually applied, whichever of the server mode: a stale initial_state reverts the client's buckaroo_state when dataflow changes overlap #998 fixes lands. A df_meta with no stats.gen leaves nothing expected, so a server that stops reporting stats cannot have a late stats_update merged.
  • Merge. A stats_update whose stats_gen is not expectedGen is dropped, on arrival and again after the payload decodes. Otherwise the payload (a wide DFEnvelope, decoded with decodeDFData) is key-merged into df_data_dict.all_stats by mergeStatRows: for each stat row the payload names, each column it carries replaces that cell, columns it does not carry keep their cells, and a stat row the table lacks is appended. A null in the payload never replaces a value that is already there, because the wide pivot pads a stat a column did not carry with null. The merge builds new row objects, a new all_stats array and a new df_data_dict object. It never mutates the decoder's cached arrays, and the new reference is what the makeLatestDictDecoder from c0a needs to see a change. all_stats may be a decoded array (the seed) or an undecoded envelope (a later initial_state), so the base is decoded first. Updates are applied one at a time, in arrival order. If a frame replaces df_data_dict while the base decodes, the merge is redone on the new dict; if the gen moved on, it is dropped.
  • Status. A final update also sets df_meta.stats to {status: "complete", tier: <update tier>, gen} in a new df_meta object, which is what c0a's inFlight rule and pinned-row placeholders read. A non-final update merges and leaves the status alone.
  • stats_aborted. For the expected gen, reason error sets the status to error (reason stats_failed) and not_requestable sets it to not_computed. Other reasons (stale, unsupported_scope, no_data) and any other gen change nothing. A stale reply is answered by the initial_state that carries the new gen; adopting current_gen without that frame would let stats for a state the client has not seen merge into the one it shows.
  • Unknown types are ignored as before.
  • Capability. withStatsCapability(wsUrl) adds caps=stats_update to a WebSocket URL: ?caps= on a bare URL, &caps= after an existing query, a comma-joined value when the host already passes caps, and the fragment stays last. Other parameters are left as the host wrote them. BuckarooServerView applies it where it opens the socket, so any host's wsUrl gets it (buckarooWsUrl is unchanged), and standalone.tsx applies it to its own URL. BuckarooView takes an IModel and builds no URL.

Jupyter and anywidget are untouched: merge semantics are WebSocket-only.

What changes

  • New packages/buckaroo-js-core/src/server/StatsChannel.ts (StatsChannel, mergeStatRows, withStatsCapability, STATS_UPDATE_CAP).
  • WebSocketModel.ts (the field and the dispatch line), BuckarooServerView.tsx (the URL), src/index.ts (exports withStatsCapability, which standalone.tsx calls), packages/js/standalone.tsx (the URL).

Tests

Three tests-only commits come first (fe046acc, 3118435a, 1fd9cff8), each pushed and watched on CI before the fix. On fe046acc and on 1fd9cff8 all 27 checks completed and exactly two failed, JS / Build + Test and Server Playwright Tests (the deploy job was skipped). On 3118435a both had already failed when the next push cancelled the rest. 1fd9cff8 contains every test that fails before the fix. The fix is 5935432d: all 27 checks completed on it, 26 succeeded (JS / Build + Test, Server Playwright Tests and all nine Python / Test jobs among them) and deploy was skipped. CI logs were not read (REST rate limit); the reasons are from running the same commits locally, where every one of those tests fails on an assertion against the stub StatsChannel.ts of the first commit.

  • src/server/StatsChannel.test.ts (new, drives a WebSocketModel with a fake socket): withStatsCapability; the merge (per-column key-merge, null padding, new row objects, an undecoded envelope as the base, other df_data_dict keys kept, a real wide parquet_b64 payload from the shared summary-stats fixture, a model with no dict or no all_stats); update order; df_meta.stats after a final and a non-final update; stats_gen (a mismatched gen dropped, the expected gen advancing on a broadcast frame, a merge discarded when a newer-gen frame arrives during the decode, a merge redone when a same-gen frame replaces the dict during the decode); stats_aborted; unknown types and infinite_resp pairing still working. The two in-flight cases and the order case hold a decode open through a mocked decodeDFData.
  • src/server/BuckarooServerView.caps.test.tsx (new): the socket opens with ?caps=stats_update and keeps a query string the host passed.
  • pw-tests/server.spec.ts: the standalone page connects with caps=stats_update (the server Playwright job).
  • src/stories/StatsUpdateChannel.stories.tsx and pw-tests/stats-update-channel.spec.ts (d6959824, Storybook Playwright job, no server): a real WebSocketModel and BuckarooView on a fake in-page server, with stats_update frames sent by button. Column chunks merge into the pinned rows and the final one completes df_meta.stats; after a search moves the client to gen 2, gen 1's late chunks are dropped and gen 2's merge. Each test fails under a matching StatsChannel mutation (frames ignored, gen checks removed, a null allowed to overwrite, no status change on final).

The new jest files are separate from WebSocketModel.test.ts, which the #998 PRs create.

Tests that pass before the fix (the guards: caps already advertised, no stats reported, an initial_state without df_meta.stats, ignored stats_aborted cases, unknown types, infinite_resp pairing) went into the fix commit with the implementation.

Run locally on the fix: jest 365 passed (334 from c0a plus 31 here); tsc -b; the five server Playwright files against a rebuilt standalone.js, 45 passed. Twelve deliberate regressions of the implementation (the arrival-time gen check, the null rule, copying rows, serialising updates, the dict re-check, the final flip, the error status, the aborted gen check, decoding the base, reading the gen off df_meta, the dispatch line, the URL) were tried: eleven make at least one test fail. The twelfth, dropping the arrival-time gen check, passes, because the check after the decode catches the same updates; the early check only skips decoding a stale payload. Dropping the post-decode check instead makes the merge loop spin; I saw that once and did not rerun it.

I also replayed frames captured from the #1024 server (a deferred /load_expr session over a xorq memtable, a capable client, one stats_request, a post_processing change, a stale and a current request) through a real WebSocketModel in a throwaway jest test: the schema-tier all_stats (one dtype row) merged into the full set, the status went to complete at tier full for gen 1 and again for gen 2, the stale reply changed nothing, and a replayed gen-1 update was dropped. That test is not committed, because it needs the other branch's server to regenerate its frames.

Why default behaviour is unchanged

No default server sends df_meta.stats, stats_update or stats_aborted, so expectedGen is undefined and the channel consumes nothing. A client that now advertises caps=stats_update is treated by the #1024 server as capable. That only matters on a deferred session (opt-in per /load_expr body field), where it is not completed synchronously. On any other session the capability changes nothing except the order broadcast_state sends in (capable clients first). A server that predates #1024 ignores the query parameter.

Before a host turns deferred delivery on: a client that advertises stats_update and never sends stats_request stays on the stats-free frame with the pinned rows as placeholders. The scheduler that sends the request is the next client phase and has to ship before any host selects deferred delivery.

Deviations from the plan

  • The expected gen is read off the model's df_meta instead of being set in the initial_state branch, so one dispatch line in WebSocketModel serves the merge and the gen tracking and the branch is not touched.
  • stats_aborted is acted on for error and not_requestable; the plan says only "handle stats_aborted".
  • Only the gen is checked, not the scope; the server accepts only raw today.
  • The plan lists the jest cases under WebSocketModel; they are in StatsChannel.test.ts to avoid an add/add conflict with the server mode: a stale initial_state reverts the client's buckaroo_state when dataflow changes overlap #998 PRs.

Not in this PR

  • The scheduler that sends stats_request, and any "Compute summary stats" control (plan 1 phase 4).
  • Model-before-decode (plan 1 phase 0b, gated on server mode: a stale initial_state reverts the client's buckaroo_state when dataflow changes overlap #998): a second initial_state that arrives while the first is still being decoded, before the model exists, is still lost.
  • A jest test for packages/js/standalone.tsx, which has no harness (importing it runs main()); the server Playwright test above covers its URL.
  • A buckaroo-js-core release and the tallyman bump.

Stack

Built on feat/rowsfirst-c0a-client-hardening (#1020), which this PR contains until it merges, so the diff includes c0a's two commits (29e1e42c tests, 405f4ded fix). This phase's commits are the three tests-only commits fe046acc, 3118435a and 1fd9cff8, and the fix commit 5935432d. The server branch #1024 is not part of this PR; its message shapes are an interface, not a dependency, and the tests use a fake socket.

🤖 Generated with Claude Code

paddymul and others added 3 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>
@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.dev37478765398

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

MCP server for Claude Code

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

…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>
#1020 landed as a squash of c0a plus two follow-up commits. Files this
branch only carried from the original c0a commits take main's version.

Semantic resolution: #1020's follow-up dropped StatsStatus, DFMetaStats,
getStatsStatus and DFMeta.stats, since nothing in the client read them
then. StatsChannel reads and writes df_meta.stats, so the StatsStatus and
DFMetaStats types and the optional DFMeta.stats field come back in
WidgetTypes.tsx. getStatsStatus, the stats_status props and the
df_meta-aware in-flight rule stay out, as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@paddymul
paddymul marked this pull request as ready for review October 6, 2026 13:57
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

…ws-first c2)

StatsUpdateChannel renders a real WebSocketModel through BuckarooView in
buckaroo mode on a fake in-page server: infinite_request gets an
infinite_resp JSON frame plus a binary frame, a buckaroo_state_change (the
search box) gets an initial_state carrying the next stats gen with pending,
schema-only stats. stats_update frames go out only on a button, one pair per
gen, so the tests choose their order against the other frames. Each gen's
mean row comes in two column chunks, age then score (final), each padding the
other column with null as the wide pivot does.

stats-update-channel.spec.ts:
- the age chunk fills its pinned cell and leaves the gen pending; the final
  score chunk fills score, keeps age (its null does not overwrite) and the
  schema dtype row, and completes df_meta.stats.
- after a search moves the client to gen 2, both of gen 1's chunks are
  dropped; gen 2's chunks merge the filtered rows' means.

Checked against StatsChannel mutations: ignoring stats frames or skipping
the status change fails both tests, removing the gen checks fails the
search test, letting a null overwrite fails the chunk test. Added to the
Storybook list in test_playwright_storybook.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@paddymul
paddymul changed the base branch from main to adr-002-rows-first-stats-delivery October 6, 2026 14:40
@paddymul
paddymul merged commit b353a77 into adr-002-rows-first-stats-delivery Oct 6, 2026
28 checks passed
paddymul added a commit that referenced this pull request Oct 6, 2026
…ability (rows-first c2) (#1025)

* test(client): failing tests for client hardening under a two-message 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>

* fix(client): harden the client for a two-message protocol (rows-first 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>

* test(client): failing tests for the stats_update channel (rows-first c2)

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>

* test(client): failing tests for stats_update payloads and an empty dict (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>

* test(client): failing test for stats_update ordering (rows-first c2)

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>

* feat(client): merge stats_update into all_stats and advertise the capability (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>

* test(client): Storybook + Playwright for the stats_update channel (rows-first c2)

StatsUpdateChannel renders a real WebSocketModel through BuckarooView in
buckaroo mode on a fake in-page server: infinite_request gets an
infinite_resp JSON frame plus a binary frame, a buckaroo_state_change (the
search box) gets an initial_state carrying the next stats gen with pending,
schema-only stats. stats_update frames go out only on a button, one pair per
gen, so the tests choose their order against the other frames. Each gen's
mean row comes in two column chunks, age then score (final), each padding the
other column with null as the wide pivot does.

stats-update-channel.spec.ts:
- the age chunk fills its pinned cell and leaves the gen pending; the final
  score chunk fills score, keeps age (its null does not overwrite) and the
  schema dtype row, and completes df_meta.stats.
- after a search moves the client to gen 2, both of gen 1's chunks are
  dropped; gen 2's chunks merge the filtered rows' means.

Checked against StatsChannel mutations: ignoring stats frames or skipping
the status change fails both tests, removing the gen checks fails the
search test, letting a null overwrite fails the chunk test. Added to the
Storybook list in test_playwright_storybook.sh.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

This branch was successfully deployed

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