Skip to content

fix(cpp-client): DH-24172: Accept rows the server re-sends during the initial snapshot - #8999

Open
kosak wants to merge 4 commits into
deephaven:mainfrom
kosak:kosak_cpp-barrage-fix-re-sends
Open

kosak wants to merge 4 commits into
deephaven:mainfrom
kosak:kosak_cpp-barrage-fix-re-sends

Conversation

@kosak

@kosak kosak commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

This PR is stacked on top of #8998. It will get simpler once that PR has been merged.

C++ port of #8996, and last of the three. While the initial snapshot of a large table is still arriving in rounds, the server tracks what the client holds as a range of positions. A row inserted ahead of the delivered region pushes the last delivered row out of that range, and the next round covers that row again, sending its data to a client that already holds it. The client inserted every included row strictly (the behavior #8997 deliberately pinned) and threw: "Some elements of [...] were already in the set".

The Java client treats such a row as an overwrite. This client holds only rows it has data for, so it takes the row out and adds it back with the new data, which reports it to the user as removed and then added. That is the one representation in which every before-and-after table in the update agrees with the index sets that describe it: overwriting in place during the add phase would change rows between AfterRemoves and AfterAdds that AddedRows does not mention. The obvious shortcut, letting key insertion tolerate an existing key, is wrong here for a different reason: AddData inserts rather than overwrites, so the row would end up held twice.

AwaitingMetadata::ProcessNextChunk now erases the removed rows, applies the shifts, hands the whole included set to Erase, which skips the rows it does not hold (from #8998) and so takes out exactly the re-sent ones, and only then adds keys for the included rows, which stays strict. The second erasure happens in the index space of the table after the first, so ImmerTableState::CombineErasures lifts its positions past the rows the first one took out before the two are reported as one removed set in the index space of the original table. With Erase now a no-op for an empty or entirely unheld set (from #8998), the special cases in ProcessRemoves are gone and it is folded into the main flow; the method is removed.

Tests: barrage_processor_test replaces "re-sent row is rejected" with two cases describing the intended result: the re-sent row is reported as removed and then added back carrying the new data, and when the same message also removes rows, the reported positions are those of the table before the message. table_state_test covers CombineErasures (the second erasure's positions count only the rows that survived the first, so each has to move past them). growing_subscription_test adds a table whose inserted rows take the lowest row keys, so that they land ahead of the snapshot. The test inserts them itself once the first round has arrived, and checks that each delivered row they pushed out of the server's range was sent again.

Verified at this commit with ./gradlew :cpp-client:testCppClient: 107 test cases, 691 assertions, 0 failures

@kosak kosak self-assigned this Oct 25, 2026
Copilot AI balanced review requested due to automatic review settings October 25, 2026 19:37
@kosak kosak added NoDocumentationNeeded NoReleaseNotesNeeded No release notes are needed. labels Oct 25, 2026
@semanticdiff-com

Copy link
Copy Markdown

Review changes with  SemanticDiff

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

No docs changes detected for 2d4684a

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The test target lacks an explicit Immer dependency, preventing compilation when dependency headers use separate installation prefixes.

1 open issue
What changed in this PR

This PR fixes C++ subscription handling when the server re-sends rows during a multi-round initial snapshot. It builds on the preceding fixes for partial snapshot delivery and removal of undelivered rows.

Changes:

  • Size incoming additions from included rows and represent re-sent rows as removals followed by additions.
  • Add held-key filtering and combine successive erasures into original row positions.
  • Add unit and live-server regression tests for snapshot delivery.
File Description
cpp-client/​deephaven/​tests/​src/​table_state_test.cc Tests partial erasures, held-key filtering, and combined erasures.
cpp-client/​deephaven/​tests/​src/​growing_subscription_test.cc Exercises multi-round snapshots against a live server.
cpp-client/​deephaven/​tests/​src/​barrage_processor_test.cc Tests snapshot message handling and re-sent rows.
cpp-client/​deephaven/​tests/​include/​private/​deephaven/​tests/​barrage_test_util.h Adds shared row and column test helpers.
cpp-client/​deephaven/​tests/​CMakeLists.txt Registers tests and exposes required private headers.
cpp-client/​deephaven/​dhcore/​src/​ticking/​space_mapper.cc Implements held-key filtering and tolerant position mapping.
cpp-client/​deephaven/​dhcore/​src/​ticking/​immer_table_state.cc Skips unheld removals and combines erasure positions.
cpp-client/​deephaven/​dhcore/​src/​ticking/​barrage_processor.cc Handles included rows and replaces re-sent rows.
cpp-client/​deephaven/​dhcore/​include/​private/​deephaven/​dhcore/​ticking/​space_mapper.h Declares held-key mapping helpers.
cpp-client/​deephaven/​dhcore/​include/​private/​deephaven/​dhcore/​ticking/​immer_table_state.h Declares held-key lookup and erasure-combination helpers.

Review effort: Balanced

@kosak
kosak force-pushed the kosak_cpp-barrage-fix-re-sends branch from 63c7ff1 to 8348c9c Compare November 2, 2026 23:23
Copilot AI balanced review requested due to automatic review settings November 2, 2026 23:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The test target lacks an explicit Immer dependency, causing compilation to fail with a missing header when Immer is installed in a separate prefix.

1 open issue

Review effort: Balanced

@kosak
kosak force-pushed the kosak_cpp-barrage-fix-re-sends branch from 8348c9c to e8a96a1 Compare November 3, 2026 00:18
Copilot AI balanced review requested due to automatic review settings November 3, 2026 00:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues were identified for the C++ client’s multi-round snapshot handling changes.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@kosak
kosak force-pushed the kosak_cpp-barrage-fix-re-sends branch from e8a96a1 to 3ae979d Compare November 3, 2026 00:42
Copilot AI balanced review requested due to automatic review settings November 3, 2026 00:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the C++ client’s multi-round snapshot row-handling changes.

No open issues 🎉

Review effort: Balanced

@kosak
kosak force-pushed the kosak_cpp-barrage-fix-re-sends branch from 3ae979d to c1b1c4d Compare November 4, 2026 17:29
Copilot AI balanced review requested due to automatic review settings November 4, 2026 17:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the C++ incremental snapshot changes.

No open issues 🎉

Review effort: Balanced

kosak added a commit that referenced this pull request Nov 4, 2026
…_included (#8997)

C++ port of #8994. First of three stacked PRs (#8998 and #8999 follow)
fixing the C++ client's handling of a ticking subscription while the
initial snapshot of a large table is still being delivered in rounds;
the C++ client has the same gaps the C# client had.

The subscription processor sized its add phase from `added_rows` and
never read `added_rows_included`. The two differ only during that
initial snapshot: a snapshot round announces the table's whole row set
in `added_rows` but carries data only for its own slice, and a delta
between rounds can carry data for rows `added_rows` does not mention
(rows that moved into the already-delivered region) and none for rows it
does (rows a later round will deliver). Sized from the wrong set, the
add phase kept consuming the record batches of later messages, which
surfaced as "There is excess data in the chunk" on a large appending
table and "Chunks have inconsistent sizes" on a wide table whose rows
are modified every cycle.

`AwaitingMetadata::ProcessNextChunk` now sizes the add phase, and
inserts keys, from `added_rows_included`, defaulting to `added_rows`
when the flatbuffer field is absent. The server omits it exactly when
the two are identical, which is every message of an established
subscription, so this mirrors the Java client's reader defaulting
`rowsIncluded` to `rowsAdded`. A row that `added_rows` announces without
data is added later, when the round carrying its data arrives.

Key insertion stays strict: during a growing snapshot the server can
legitimately re-send a row this client already holds, and for now such a
row fails loudly inside `SpaceMapper` rather than being silently
inserted twice. Handling it as an overwrite is a separate change
(#8999); the "re-sent row is rejected" case pins the current behavior so
the hand-off is explicit. The same holds for removes: a full
subscription is sent the server's entire removed set, which during a
growing snapshot can name rows this client has not been given yet. For
now such a remove fails loudly inside `SpaceMapper`; skipping those rows
is the next change (#8998), and the "remove of undelivered rows is
rejected" case pins the hand-off.

Tests: `barrage_processor_test` drives `BarrageProcessor` without a
server, feeding hand-built `BarrageUpdateMetadata` flatbuffers and
record batches one at a time, with a test-side encoder for the row-set
wire format. It builds that metadata from the flatbuffer schema dhcore
vendors, so the tests target gains `dhcore/flatbuf` and the vendored
flatbuffers headers on its include path. The cases cover a snapshot
round sized from the included set, included rows spanning record
batches, later rounds re-announcing the whole row set, a delta whose
included set is empty, a row scoped in with empty `added_rows`, and an
absent field meaning "same as `added_rows`", and, for DH-24154, slices
arriving out of key order. `growing_subscription_test` subscribes to the
two tables from the bug report against a live server. The part of a
table that changes is an input table, empty when the subscription opens;
the test waits for the first snapshot round to arrive and then adds rows
to it with a second script, so that the delta is applied while the rest
of the snapshot is still being delivered, and it asserts that the delta
was observed. A column computed by a Python function that sleeps a few
microseconds per row keeps the rounds small: the server sizes a round to
about a quarter of an update graph cycle, so the snapshot spans several
cycles and the delta has room to land between rounds.

Verified at this commit with `./gradlew :cpp-client:testCppClient`: 99
test cases, 649 assertions, 0 failures

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
kosak and others added 4 commits November 4, 2026 14:50
…scription has not received

These fail against the current client, which converts every removed key
strictly and throws out of SpaceMapper when one is not held. A full
subscription is sent the server's entire removed set, and while the initial
snapshot of a large table is still arriving in rounds, that set can name rows
this client has not been given yet.

table_state_test pins down ImmerTableState::Erase against a table holding a
known set of keys; dhcore's private headers are now on the tests' include
path for it, which is safe because dhcore exports every symbol, and the tests
find Immer for themselves because dhcore links it privately.
barrage_processor_test replaces the test that pinned the strict rejection with
the two message shapes: a removed set entirely beyond the delivered region,
which must leave the table untouched and report that the way an empty removed
set does, and one straddling it, which must erase only the held rows.
growing_subscription_test subscribes to the table from the bug report, whose
rows are removed from the top of the key space while the snapshot delivers it
from the bottom. The test issues the removes itself once the first round has
arrived, a few from the bottom so that the delta is visible to the client and
many from the top, and checks that it arrived while the rows from the top were
still undelivered. The helpers the server-free tests share move into
barrage_test_util.h. Ports of the C# tests for DH-24171.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…not received

A full subscription is sent the server's entire removed set, and while the
initial snapshot of a large table is still arriving in rounds, that set can
name rows this client has not been given yet. ImmerTableState::Erase converted
every removed key strictly, so such a message threw out of SpaceMapper, and
even a partially held interval would have sliced every column by the
interval's full width rather than by the rows actually held.

SpaceMapper gains ConvertPresentKeysToIndices, which maps each removed
interval to the positions of only the keys it holds; within one interval those
occupy consecutive ranks, so each interval still maps to one range. Erase uses
it, slices each column by the ranks held in each interval, and does nothing at
all when none of the named rows is held. ProcessRemoves then treats a removed
set that erased nothing like an empty one, handing out the same table before
and after so that consumers relying on pointer equality still see "unchanged".
This is the C++ port of the C# fix for DH-24171.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ring the initial snapshot

These fail against the current client, which inserts every included row
strictly and throws out of SpaceMapper when one is already held: "Some
elements of [...] were already in the set".

While the initial snapshot of a large table is still arriving in rounds, the
server tracks what the client holds as a range of positions. A row inserted
ahead of the delivered region pushes the last delivered row out of that range,
and the next round covers that row again, sending its data to a client that
already has it. The Java client treats such a row as an overwrite.

barrage_processor_test replaces the test that pinned the strict rejection with
two that describe the intended result: the re-sent row is reported as removed
and then added back carrying the new data, and when the same message also
removes rows, the reported positions are those of the table before the
message. growing_subscription_test subscribes to a table whose inserted rows
take the lowest row keys, so that they land ahead of the snapshot. The test
inserts them itself once the first round has arrived, and checks that each
delivered row they pushed out of the server's range was sent again. Ports of
the C# tests for DH-24171.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… initial snapshot

While the initial snapshot of a large table is still arriving in rounds, the
server tracks what the client holds as a range of positions. A row inserted
ahead of the delivered region pushes the last delivered row out of that range,
and the next round covers that row again, sending its data to a client that
already holds it. The client inserted every included row strictly and threw:
"Some elements of [...] were already in the set".

The Java client treats such a row as an overwrite. This client holds only rows
it has data for, so it takes the row out and adds it back with the new data,
which reports it to the user as removed and then added. That is the one
representation in which every before-and-after table in the update agrees
with the index sets that describe it; overwriting in place during the add
phase would change rows between AfterRemoves and AfterAdds that AddedRows
does not mention.

AwaitingMetadata now erases the removed rows, applies the shifts, hands the
whole included set to Erase, which skips the rows it does not hold and so
takes out exactly the re-sent ones, and only then adds keys for the included
rows, which stays strict. The second erasure happens in the index space of the
table after the first, so ImmerTableState::CombineErasures lifts its positions
past the rows the first one took out before the two are reported as one
removed set. With Erase now a no-op for an empty or entirely unheld set, the
special cases in ProcessRemoves are gone and it is folded into the main flow.
This is the C++ port of the C# fix for DH-24171.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kosak
kosak force-pushed the kosak_cpp-barrage-fix-re-sends branch from c1b1c4d to 2d4684a Compare November 4, 2026 19:50
Copilot AI balanced review requested due to automatic review settings November 4, 2026 19:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues block the snapshot row-replacement changes.

No open issues 🎉

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants