Repository navigation
feat(client): merge stats_update into all_stats and advertise the capability (rows-first c2) - #1025
Merged
paddymul merged 8 commits intoOct 6, 2026
Conversation
…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>
Contributor
📦 TestPyPI package publishedpip install --index-strategy unsafe-best-match --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ buckaroo==0.15.9.dev37478765398or 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.dev37478765398MCP server for Claude Codeclaude 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>
This was referenced Oct 4, 2026
#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
marked this pull request as ready for review
October 6, 2026 13:57
|
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
changed the base branch from
main
to
adr-002-rows-first-stats-delivery
October 6, 2026 14:40
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The server side of the stats wire is on its own branch (#1024): a client that connects with
?caps=stats_updateto a deferred session gets a firstinitial_statewithout data-derived stats (df_meta.stats.status == "pending"), asks for them withstats_request {stats_gen, scope}, and receives astats_update {stats_gen, scope, tier, final, payload, elapsed_ms}or astats_aborted. No client handles any of that yet:WebSocketModeldrops unknown message types, so astats_updateis lost.stats_updatefor a state it has left (a/load, a dataflow-field change) could not be told from a current one.BuckarooServerViewopen 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:WebSocketModelmergesstats_update, drops a mismatchedstats_gen, advancesexpectedGenon a broadcast frame with noreply_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 forelapsed_ms. Message names and shapes follow the server branchfeat/rowsfirst-s3-stats-wire-server(#1024), read from itsstats_wire.pyand checked against frames captured from that branch (see Tests).Approach
A new module,
src/server/StatsChannel.ts, holds the client half.WebSocketModelgets one field and one line inonmessage: it constructsnew StatsChannel(this)and returns early whenstats.handle(msg)consumes a frame. Nothing else inWebSocketModel.tschanges, so the rewrites in #1013, #1014 and #1015 conflict with it as little as possible.StatsChannel.expectedGenis thedf_meta.stats.genof thedf_metathe model currently holds, read each time. It starts from the frame the model was built from, and everyinitial_statethe model applies moves it, a broadcast frame with noreply_seqincluded. Reading the model rather than theinitial_statebranch 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. Adf_metawith nostats.genleaves nothing expected, so a server that stops reporting stats cannot have a latestats_updatemerged.stats_updatewhosestats_genis notexpectedGenis dropped, on arrival and again after the payload decodes. Otherwise the payload (a wideDFEnvelope, decoded withdecodeDFData) is key-merged intodf_data_dict.all_statsbymergeStatRows: 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. Anullin the payload never replaces a value that is already there, because the wide pivot pads a stat a column did not carry withnull. The merge builds new row objects, a newall_statsarray and a newdf_data_dictobject. It never mutates the decoder's cached arrays, and the new reference is what themakeLatestDictDecoderfrom c0a needs to see a change.all_statsmay be a decoded array (the seed) or an undecoded envelope (a laterinitial_state), so the base is decoded first. Updates are applied one at a time, in arrival order. If a frame replacesdf_data_dictwhile the base decodes, the merge is redone on the new dict; if the gen moved on, it is dropped.finalupdate also setsdf_meta.statsto{status: "complete", tier: <update tier>, gen}in a newdf_metaobject, which is what c0a'sinFlightrule and pinned-row placeholders read. A non-final update merges and leaves the status alone.stats_aborted. For the expected gen, reasonerrorsets the status toerror(reasonstats_failed) andnot_requestablesets it tonot_computed. Other reasons (stale,unsupported_scope,no_data) and any other gen change nothing. Astalereply is answered by theinitial_statethat carries the new gen; adoptingcurrent_genwithout that frame would let stats for a state the client has not seen merge into the one it shows.withStatsCapability(wsUrl)addscaps=stats_updateto a WebSocket URL:?caps=on a bare URL,&caps=after an existing query, a comma-joined value when the host already passescaps, and the fragment stays last. Other parameters are left as the host wrote them.BuckarooServerViewapplies it where it opens the socket, so any host'swsUrlgets it (buckarooWsUrlis unchanged), andstandalone.tsxapplies it to its own URL.BuckarooViewtakes anIModeland builds no URL.Jupyter and anywidget are untouched: merge semantics are WebSocket-only.
What changes
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(exportswithStatsCapability, whichstandalone.tsxcalls),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. Onfe046accand on1fd9cff8all 27 checks completed and exactly two failed,JS / Build + TestandServer Playwright Tests(thedeployjob was skipped). On3118435aboth had already failed when the next push cancelled the rest.1fd9cff8contains every test that fails before the fix. The fix is5935432d: all 27 checks completed on it, 26 succeeded (JS / Build + Test,Server Playwright Testsand all ninePython / Testjobs among them) anddeploywas 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 stubStatsChannel.tsof the first commit.src/server/StatsChannel.test.ts(new, drives aWebSocketModelwith a fake socket):withStatsCapability; the merge (per-column key-merge, null padding, new row objects, an undecoded envelope as the base, otherdf_data_dictkeys kept, a real wideparquet_b64payload from the shared summary-stats fixture, a model with no dict or noall_stats); update order;df_meta.statsafter 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 andinfinite_resppairing still working. The two in-flight cases and the order case hold a decode open through a mockeddecodeDFData.src/server/BuckarooServerView.caps.test.tsx(new): the socket opens with?caps=stats_updateand keeps a query string the host passed.pw-tests/server.spec.ts: the standalone page connects withcaps=stats_update(the server Playwright job).src/stories/StatsUpdateChannel.stories.tsxandpw-tests/stats-update-channel.spec.ts(d6959824, Storybook Playwright job, no server): a realWebSocketModelandBuckarooViewon a fake in-page server, withstats_updateframes sent by button. Column chunks merge into the pinned rows and the final one completesdf_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 matchingStatsChannelmutation (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_statewithoutdf_meta.stats, ignoredstats_abortedcases, unknown types,infinite_resppairing) 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 rebuiltstandalone.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 offdf_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_exprsession over a xorq memtable, a capable client, onestats_request, apost_processingchange, a stale and a current request) through a realWebSocketModelin a throwaway jest test: the schema-tierall_stats(onedtyperow) merged into the full set, the status went tocompleteat tierfullfor 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_updateorstats_aborted, soexpectedGenis undefined and the channel consumes nothing. A client that now advertisescaps=stats_updateis treated by the #1024 server as capable. That only matters on a deferred session (opt-in per/load_exprbody field), where it is not completed synchronously. On any other session the capability changes nothing except the orderbroadcast_statesends 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_updateand never sendsstats_requeststays 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
df_metainstead of being set in theinitial_statebranch, so one dispatch line inWebSocketModelserves the merge and the gen tracking and the branch is not touched.stats_abortedis acted on forerrorandnot_requestable; the plan says only "handlestats_aborted".scope; the server accepts onlyrawtoday.WebSocketModel; they are inStatsChannel.test.tsto 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
stats_request, and any "Compute summary stats" control (plan 1 phase 4).initial_statethat arrives while the first is still being decoded, before the model exists, is still lost.packages/js/standalone.tsx, which has no harness (importing it runsmain()); the server Playwright test above covers its URL.buckaroo-js-corerelease 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 (29e1e42ctests,405f4dedfix). This phase's commits are the three tests-only commitsfe046acc,3118435aand1fd9cff8, and the fix commit5935432d. 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