Skip to content

feat(qwp): add table options API to name the designated timestamp column - #76

Open
jerrinot wants to merge 3 commits into
mainfrom
jh_qwp_table_options
Open

feat(qwp): add table options API to name the designated timestamp column#76
jerrinot wants to merge 3 commits into
mainfrom
jh_qwp_table_options

Conversation

@jerrinot

Copy link
Copy Markdown
Contributor

Sender users could not control the designated timestamp column name of
auto-created tables, QWP servers always created timestamp. This PR adds a
table-options API to set it:

sender.table("trades").tableOptions().designatedTimestamp("ts");

Changes

  • New Sender.TableOptions interface, obtained via tableOptions() after
    table(). Options are namespaced so future create-time hints
    (partitioning, storage policies) land beside this one instead of on
    Sender directly. ILP senders reject the call — ILP cannot express it.
  • Both QWP transports (WebSocket, UDP) encode the hint in the new per-table
    options trailer. When no hint is set, the emitted bytes are identical to
    the previous encoding, pinned by a golden-bytes test.
  • The WebSocket client reads the server's X-QWP-Table-Options capability
    header and logs one warning when the hint will not be honored.

Limitations

  • Create-only hint: existing tables and pre-support servers ignore it.
  • The returned options object is a reused per-sender instance; retaining it
    across table selections is documented as unsupported.
  • The hint can be changed only while a table has no buffered rows.

Tandem PR: questdb/questdb#7435

QWP senders let callers name the designated timestamp column that the
server creates when it auto-creates a missing table. The sender API
exposes the hint through a Sender.TableOptions flyweight obtained via
sender.table(name).tableOptions().designatedTimestamp(col), keeping
Sender itself free of flat per-option methods so future create-time
hints (partitioning, storage policies) can land beside it.

The wire encoding sets header flag 0x20 and appends a per-table TLV
options trailer (tag 0x01 = designated timestamp name) plus a u32
length footer after the table blocks. Old servers ignore the unknown
flag and the trailing bytes, so frames degrade to the conventional
timestamp column name; when no hint is set the emitted bytes stay
byte-identical to the legacy encoding, which a golden-bytes test pins.
Both the WebSocket and UDP transports carry the trailer, and
store-and-forward replays the self-describing frames safely against
older servers.

The WebSocket client reads the X-QWP-Table-Options capability header
during the upgrade and logs one warning when a configured hint reaches
a server that does not advertise support. ILP senders reject the API.

The hint is sticky per table, validated to 127 UTF-8 bytes, and
re-declarable while a table buffer holds no rows, so pooled sender
borrowers can re-declare it instead of hitting an unclearable
conflict. The pooled sender guards stale handles behind its lease
generation. Tests cover trailer layouts and mixed presence, UDP
framing, upgrade-header parsing, stale and retained flyweight
references, options-only rows, and lease rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jerrinot jerrinot added enhancement New feature or request tandem labels Jul 28, 2026
@jerrinot jerrinot changed the title Name designated timestamps in QWP auto-create feat(qwp): add table options API to name the designated timestamp column Jul 28, 2026
jerrinot and others added 2 commits July 28, 2026 18:08
TableOptionsImpl.designatedTimestamp() invalidated the cached base
estimate only on the first name set. QwpTableBuffer permits renaming
the designated timestamp while rowCount == 0, and the cache (keyed on
column count alone) folds in the trailer size, which depends on the
name length. After a flush kept the buffer's name and cache alive, a
rename reused the shorter-trailer estimate, so a bounded UDP sender
could emit datagrams above maxDatagramSize. The fix invalidates the
cache on every accepted set/change; the committedDatagramEstimate bump
stays under previousName == null because a rename requires
rowCount == 0, where the committed estimate is already 0.

The regression test asserts committedDatagramEstimate covers the
actual datagram after a short-name flush and a long-name rename
(fails at 43 vs 155 bytes without the fix).

A new encoder test mirrors flushPendingRowsSplit: consecutive
single-table messages on a reused encoder each carry the
FLAG_TABLE_OPTIONS flag and trailer, and a table without options does
not inherit them.

The FLAG_TABLE_OPTIONS javadoc now records the forward-compat
guarantee the ungated emission relies on: pre-table-options servers
validate only magic, version and payload length, and iterate exactly
the header-declared table count, so unknown flag bits and trailer
bytes after the last table body are ignored (verified against the
server decoder at 51b235b9ca~1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
QwpTableBuffer.setDesignatedTimestampName guarded only the rename
branch on rowCount, so a first set was accepted with rows already
buffered. On the UDP sender, TableOptionsImpl.designatedTimestamp
then bumped committedDatagramEstimate by the trailer size without
flushing or re-checking the cap. Since only commitCurrentRow checks
maxDatagramSize, a subsequent flush() shipped a datagram exceeding
the configured cap: IP fragmentation, or EMSGSIZE and silent data
loss near the 65507-byte limit.

setDesignatedTimestampName now rejects a first set while the buffer
holds rows, symmetric with the rename guard. This makes the estimate
bump in TableOptionsImpl unreachable, so this commit deletes it and
keeps only the base-estimate invalidation. Javadocs on QwpTableBuffer
and Sender.TableOptions now state the name may be set or re-declared
only while the table has no buffered rows.

Regression tests cover the buffer-level throw and the sender-level
scenario: the late set throws, the flushed datagram carries no
table-options trailer, and all packets stay within maxDatagramSize.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 135 / 138 (97.83%)

file detail

path covered line new line coverage
🔵 io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java 21 23 91.30%
🔵 io/questdb/client/cutlass/http/client/WebSocketClient.java 23 24 95.83%
🔵 io/questdb/client/impl/PooledSender.java 8 8 100.00%
🔵 io/questdb/client/cutlass/qwp/client/QwpWebSocketEncoder.java 18 18 100.00%
🔵 io/questdb/client/cutlass/qwp/client/QwpUdpSender.java 30 30 100.00%
🔵 io/questdb/client/cutlass/qwp/client/QwpColumnWriter.java 18 18 100.00%
🔵 io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java 16 16 100.00%
🔵 io/questdb/client/Sender.java 1 1 100.00%

@jerrinot

Copy link
Copy Markdown
Contributor Author

M2/M3 confirmed against the diff source (setDesignatedTimestampName: the else if (!Chars.equals(...)) path skips the rowCount != 0 throw, so a same-name re-set while rows are buffered is a silent no-op — contradicting the javadoc's "any set while rows are buffered throws"). Here's the review.


Code Review: PR #76feat(qwp): add table options API to name the designated timestamp column

Reviewed at level 3 (full mission-critical pass): change-surface map, self-verification of all load-bearing wire-format/estimate logic, plus three parallel bug-hunters (cross-context callers, concurrency/resource/pool, fresh-context adversarial — the last cross-checked the client bytes against the parent-repo server decoder QwpMessageCursor.parseTableOptions).

Committed-binary gate: clean — no binary files in the diff.

This is a careful, well-tested PR. No critical or blocking defect. Every high-risk vector — trailer byte arithmetic, the UDP datagram estimate, store-and-forward replay, flag-bit allocation, header parsing, concurrency — holds up under byte-level verification against the golden-byte tests.

Moderate

M1 — The client emits FLAG_TABLE_OPTIONS + trailer unconditionally, even to servers that never advertised support. Confirm the server contract before merging ahead of the tandem PR. (in-diff, by-design, external dependency)
QwpUdpSender.sendEncodedPayload/appendTableOptionsForUdp and QwpWebSocketEncoder.finishMessage set the flag and append the trailer whenever a name is declared, without gating on the negotiated capability — warnIfTableOptionsUnsupported only logs. This is deliberate and correct for the client: store-and-forward frames must replay to arbitrary servers, so gating on a per-connection handshake is wrong. QwpConstants.java:54-64 documents the reliance on the server forward-compat guarantee: pre-support servers ignore unknown flag bit 0x20 and never read past the last table body (the trailer rides inside the existing payloadLength envelope). The fresh-context agent verified the current server decoder agrees byte-for-byte; the unverifiable part is a genuinely old server. Nothing to change here — just don't merge the client ahead of a verified server contract, and consider a mixed-version e2e test. If the guarantee holds, an old server silently creates timestamp and the log warning is the only signal; if it doesn't, frames get rejected.

Minor

M2 — Re-declaring the same name while rows are buffered is a silent no-op, contradicting the javadoc. (in-diff)
QwpTableBuffer.setDesignatedTimestampName: the else if (!Chars.equals(designatedTimestampName, columnName)) guard means an identical-name re-set skips both the rowCount != 0 check and the throw. Both the method javadoc and Sender.TableOptions.designatedTimestamp state "setting it while rows are buffered throws." Behavior is benign (idempotent; trailer size unchanged; the UDP base-estimate cache is only read at rowCount == 0), but the doc/behavior mismatch is real and untested. Fix: move the rowCount != 0 guard ahead of the equals-check, or soften the javadoc to "changing it while rows are buffered throws." One-line fix.

M3 — Stale explanatory comment in QwpUdpSender.TableOptionsImpl.designatedTimestamp. (in-diff)
The comment claims "Any accepted set requires rowCount == 0" — false for the M2 same-name path (accepted with rowCount > 0). The conclusion (no committedDatagramEstimate adjustment needed) still holds, but because a same-name set can't change the trailer size, not for the stated reason. Fix alongside M2.

Downgraded (verified false positives)

  • UTF-8 length mismatch (validation vs encoding)Utf8s.utf8Bytes (validation) and NativeBufferWriter.utf8Length are the same function, also what putString writes. Byte-exact.
  • reset() wiping the sticky name — the null-assignment is in clear(), not reset(); sticky-across-flush contract holds (verified directly).
  • FLAG_TABLE_OPTIONS = 0x20 collision — free bit (existing 0x01, 0x04, 0x08, 0x10).
  • Header parser matching "10"/"21" as tag 1 — exact "1".equals(token.trim()), not a substring test; no infinite loop / OOB; reset to false before each upgrade.
  • payloadStart promoted to a field — encoder is single-threaded, each entry point re-stamps it; pinned by the byte-identical golden test.
  • UDP estimate under-counting the trailergetSingleTableOptionsTrailerSize is byte-exact vs writeTableOptions+putInt, folded into the base estimate and invalidated on every rename; estimate >= actual holds.
  • Concurrency / pool staleness / leak — capability volatiles gate only logging, never wire bytes; PooledTableOptions guards generation on every call; flyweights hold no native memory.
  • Missing tableOptions() override — all three QWP senders override; ILP (AbstractLineHttpSender) inherits the throwing default; PooledSender delegates with a generation guard.

Test coverage

Strong. Golden-byte assertions on both transports, sticky/reset semantics, stale/retained-reference retargeting, estimate invalidation on rename, ILP rejection, header parser (present/absent/other-tag), mixed-presence and consecutive-split-message trailers, pool generation guard. The reflection in WebSocketClientTest/SenderLeaseGenerationTest reuses the file's existing pattern for package-private internals — acceptable. Only gap: M2's no-op path is untested (which is why the doc drift slipped through).

Summary

  • Verdict: approve. No blocking issues. M2/M3 are one-line doc/comment fixes worth folding into this branch; M1 is a cross-repo release-ordering check, not a code change.
  • 3 findings verified, 8 dropped as false positives after source verification.
  • Split: 3 in-diff, 0 out-of-diff. Zero out-of-diff is a genuine result, not an underrun — the feature is additive and the trailer travels inside the existing payload-length envelope; every encoder/UDP-send caller (flushPendingRows, flushPendingRowsSplit, sendCommitMessage, all UDP paths) was traced and is safe.

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

Labels

enhancement New feature or request tandem

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants