Skip to content

Commit a96fed7

Browse files
kafka1991claude
andcommitted
test(qwp): cover the per-query SYMBOL dict reset flag on the wire
QwpQueryClientQueryFlagsTest drives execute() against the mock WebSocket server, captures the emitted QUERY_REQUEST, and asserts the optional query_flags trailer: - reset flag + server CAP_QUERY_FLAGS -> QUERY_FLAG_RESET_DICT trailer - the trailer is the only difference from the byte-identical flag-off baseline - no capability -> flag dropped; the flag-less overloads send no trailer TestWebSocketServer gains setCapabilities(int) so its SERVER_INFO frame can advertise CAP_QUERY_FLAGS; the default of 0 keeps the existing byte layout for every other test. Covers the previously-untested client paths: QwpQueryClient.resolveQueryFlags and the execute(..., resetSymbolDict) overloads, plus the QwpEgressIoThread query_flags trailer append. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4ec260f commit a96fed7

2 files changed

Lines changed: 220 additions & 3 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/*+*****************************************************************************
2+
* ___ _ ____ ____
3+
* / _ \ _ _ ___ ___| |_| _ \| __ )
4+
* | | | | | | |/ _ \/ __| __| | | | _ \
5+
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
6+
* \__\_\\__,_|\___||___/\__|____/|____/
7+
*
8+
* Copyright (c) 2014-2019 Appsicle
9+
* Copyright (c) 2019-2026 QuestDB
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*
23+
******************************************************************************/
24+
25+
package io.questdb.client.test.cutlass.qwp.client;
26+
27+
import io.questdb.client.cutlass.qwp.client.QwpColumnBatch;
28+
import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler;
29+
import io.questdb.client.cutlass.qwp.client.QwpEgressMsgKind;
30+
import io.questdb.client.cutlass.qwp.client.QwpQueryClient;
31+
import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
32+
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
33+
import org.junit.Assert;
34+
import org.junit.Test;
35+
36+
import java.io.IOException;
37+
import java.nio.ByteBuffer;
38+
import java.nio.ByteOrder;
39+
import java.util.Arrays;
40+
import java.util.concurrent.TimeUnit;
41+
import java.util.concurrent.atomic.AtomicReference;
42+
43+
/**
44+
* Wire coverage for the per-query {@code query_flags} trailer that
45+
* {@link QwpQueryClient#execute(CharSequence, QwpColumnBatchHandler, boolean)}
46+
* adds. The mock server captures the raw {@code QUERY_REQUEST} the client emits
47+
* and replies with an {@code EXEC_DONE} so {@code execute} returns; the test
48+
* then inspects the captured bytes. Pins:
49+
* <ul>
50+
* <li>{@code resetSymbolDict=true} against a server advertising
51+
* {@link QwpEgressMsgKind#CAP_QUERY_FLAGS} appends the
52+
* {@link QwpEgressMsgKind#QUERY_FLAG_RESET_DICT} trailer;</li>
53+
* <li>the trailer is the only difference from the flag-off baseline, which
54+
* stays byte-identical;</li>
55+
* <li>without the capability the flag is dropped (no trailer);</li>
56+
* <li>the flag-less overloads never append a trailer.</li>
57+
* </ul>
58+
* The decode -&gt; dict-reset effect on the server, and the end-to-end honouring
59+
* of the flag, are covered against a live server in the questdb repo
60+
* (QwpEgressQueryFlagsResetTest, QwpEgressCacheResetWireTest).
61+
*/
62+
public class QwpQueryClientQueryFlagsTest {
63+
64+
private static final QwpColumnBatchHandler NOOP_HANDLER = new QwpColumnBatchHandler() {
65+
@Override
66+
public void onBatch(QwpColumnBatch batch) {
67+
}
68+
69+
@Override
70+
public void onEnd(long totalRows) {
71+
}
72+
73+
@Override
74+
public void onError(byte status, String message) {
75+
}
76+
};
77+
78+
@Test
79+
public void testFlagDroppedWhenServerLacksCapability() throws Exception {
80+
byte[] frame = runAndCapture(0, c -> c.execute("SELECT 1", NOOP_HANDLER, true));
81+
Assert.assertEquals("flag must be dropped when the server does not advertise CAP_QUERY_FLAGS",
82+
-1L, queryFlagsTrailer(frame));
83+
}
84+
85+
@Test
86+
public void testLegacyThreeArgOverloadSendsNoTrailer() throws Exception {
87+
byte[] frame = runAndCapture(QwpEgressMsgKind.CAP_QUERY_FLAGS, c -> c.execute("SELECT 1", null, NOOP_HANDLER));
88+
Assert.assertEquals(-1L, queryFlagsTrailer(frame));
89+
}
90+
91+
@Test
92+
public void testLegacyTwoArgOverloadSendsNoTrailer() throws Exception {
93+
byte[] frame = runAndCapture(QwpEgressMsgKind.CAP_QUERY_FLAGS, c -> c.execute("SELECT 1", NOOP_HANDLER));
94+
Assert.assertEquals(-1L, queryFlagsTrailer(frame));
95+
}
96+
97+
@Test
98+
public void testResetFlagAppendsTrailerWhenCapabilityAdvertised() throws Exception {
99+
byte[] frame = runAndCapture(QwpEgressMsgKind.CAP_QUERY_FLAGS, c -> c.execute("SELECT 1", NOOP_HANDLER, true));
100+
Assert.assertEquals((long) QwpEgressMsgKind.QUERY_FLAG_RESET_DICT, queryFlagsTrailer(frame));
101+
}
102+
103+
@Test
104+
public void testResetTrailerIsTheOnlyDifferenceFromBaseline() throws Exception {
105+
byte[] off = runAndCapture(QwpEgressMsgKind.CAP_QUERY_FLAGS, c -> c.execute("SELECT 1", NOOP_HANDLER, false));
106+
byte[] on = runAndCapture(QwpEgressMsgKind.CAP_QUERY_FLAGS, c -> c.execute("SELECT 1", NOOP_HANDLER, true));
107+
// request_id increments per execute; both run on a fresh client so they
108+
// already match, but zero it to keep the comparison about the trailer.
109+
zeroRequestId(off);
110+
zeroRequestId(on);
111+
Assert.assertEquals("reset frame must be the baseline plus a one-byte trailer", off.length + 1, on.length);
112+
Assert.assertArrayEquals("baseline bytes must be untouched", off, Arrays.copyOf(on, off.length));
113+
Assert.assertEquals(QwpEgressMsgKind.QUERY_FLAG_RESET_DICT, on[on.length - 1]);
114+
}
115+
116+
private static byte[] buildExecDone(byte[] queryRequest) {
117+
int bodyLen = 1 + 8 + 1 + 1; // msg_kind + request_id + op_type + rows_affected varint
118+
byte[] frame = new byte[QwpConstants.HEADER_SIZE + bodyLen];
119+
ByteBuffer bb = ByteBuffer.wrap(frame).order(ByteOrder.LITTLE_ENDIAN);
120+
bb.put((byte) 'Q').put((byte) 'W').put((byte) 'P').put((byte) '1');
121+
bb.put((byte) 1); // version
122+
bb.put((byte) 0); // flags
123+
bb.putShort((short) 0); // table_count
124+
bb.putInt(bodyLen); // payload_length
125+
bb.put(QwpEgressMsgKind.EXEC_DONE);
126+
bb.put(queryRequest, 1, 8); // echo request_id verbatim
127+
bb.put((byte) 0); // op_type
128+
bb.put((byte) 0); // rows_affected = 0
129+
return frame;
130+
}
131+
132+
/**
133+
* Parses the {@code QUERY_REQUEST} the client emitted and returns the
134+
* decoded {@code query_flags} trailer, or {@code -1} when no trailer was
135+
* appended. Assumes no binds (the tests use none).
136+
*/
137+
private static long queryFlagsTrailer(byte[] f) {
138+
Assert.assertTrue("captured frame is too short", f.length > 1 + 8);
139+
Assert.assertEquals("captured frame must be a QUERY_REQUEST", QwpEgressMsgKind.QUERY_REQUEST, f[0]);
140+
int[] p = {1 + 8}; // skip msg_kind + request_id
141+
long sqlLen = readVarint(f, p);
142+
p[0] += (int) sqlLen;
143+
readVarint(f, p); // initial_credit
144+
long bindCount = readVarint(f, p);
145+
Assert.assertEquals("test frames carry no binds", 0L, bindCount);
146+
if (p[0] >= f.length) {
147+
return -1L;
148+
}
149+
return readVarint(f, p);
150+
}
151+
152+
private static long readVarint(byte[] buf, int[] pos) {
153+
long value = 0;
154+
int shift = 0;
155+
while (true) {
156+
byte b = buf[pos[0]++];
157+
value |= (long) (b & 0x7F) << shift;
158+
if ((b & 0x80) == 0) {
159+
return value;
160+
}
161+
shift += 7;
162+
}
163+
}
164+
165+
private static byte[] runAndCapture(int serverCapabilities, ExecuteAction action) throws Exception {
166+
CapturingQueryServer handler = new CapturingQueryServer();
167+
try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
168+
server.setSendServerInfo(true);
169+
server.setCapabilities(serverCapabilities);
170+
int port = server.getPort();
171+
server.start();
172+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
173+
try (QwpQueryClient client = QwpQueryClient.fromConfig(
174+
"ws::addr=localhost:" + port + ";auth_timeout_ms=2000;")) {
175+
client.connect();
176+
Assert.assertTrue("client must connect", client.isConnected());
177+
action.run(client);
178+
}
179+
}
180+
byte[] frame = handler.captured.get();
181+
Assert.assertNotNull("server never received a QUERY_REQUEST", frame);
182+
return frame;
183+
}
184+
185+
private static void zeroRequestId(byte[] frame) {
186+
Arrays.fill(frame, 1, 1 + 8, (byte) 0);
187+
}
188+
189+
@FunctionalInterface
190+
private interface ExecuteAction {
191+
void run(QwpQueryClient client);
192+
}
193+
194+
private static final class CapturingQueryServer implements TestWebSocketServer.WebSocketServerHandler {
195+
final AtomicReference<byte[]> captured = new AtomicReference<>();
196+
197+
@Override
198+
public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
199+
if (data.length == 0 || data[0] != QwpEgressMsgKind.QUERY_REQUEST) {
200+
return;
201+
}
202+
captured.compareAndSet(null, data);
203+
try {
204+
client.sendBinary(buildExecDone(data));
205+
} catch (IOException e) {
206+
// best-effort: a failed reply surfaces to the client as a transport error
207+
}
208+
}
209+
}
210+
}

core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public class TestWebSocketServer implements Closeable {
8383
// QwpQueryClient tests enable this; ingress sender tests leave it off so their
8484
// connections carry only ACK frames.
8585
private volatile boolean sendServerInfo;
86+
private volatile int capabilities;
8687
// When non-null the next handshake responds with HTTP 421 Misdirected
8788
// Request + X-QuestDB-Role: <rejectingRole>, mimicking a server whose
8889
// QwpServerInfoProvider reports REPLICA / PRIMARY_CATCHUP. Set after
@@ -229,7 +230,13 @@ public void setSendServerInfo(boolean sendServerInfo) {
229230
this.sendServerInfo = sendServerInfo;
230231
}
231232

232-
private static byte[] buildServerInfoFrame(byte role) {
233+
// Advertised SERVER_INFO capabilities. CAP_ZONE is unsupported here: the
234+
// frame builder emits no zone_id trailer.
235+
public void setCapabilities(int capabilities) {
236+
this.capabilities = capabilities;
237+
}
238+
239+
private static byte[] buildServerInfoFrame(byte role, int capabilities) {
233240
byte[] clusterId = "questdb".getBytes(StandardCharsets.UTF_8);
234241
byte[] nodeId = "test-node".getBytes(StandardCharsets.UTF_8);
235242
int bodyLen = 1 + 1 + 8 + 4 + 8 + 2 + clusterId.length + 2 + nodeId.length;
@@ -242,7 +249,7 @@ private static byte[] buildServerInfoFrame(byte role) {
242249
bb.put((byte) 0x18); // SERVER_INFO msg_kind
243250
bb.put(role);
244251
bb.putLong(0L); // epoch
245-
bb.putInt(0); // capabilities (no CAP_ZONE -> no zone_id trailer)
252+
bb.putInt(capabilities); // CAP_ZONE unsupported here -> no zone_id trailer
246253
bb.putLong(1L); // server_wall_ns (positive)
247254
bb.putShort((short) clusterId.length);
248255
bb.put(clusterId);
@@ -567,7 +574,7 @@ void start() {
567574

568575
try {
569576
if (sendServerInfo) {
570-
sendBinary(buildServerInfoFrame(roleByte(advertisedRole)));
577+
sendBinary(buildServerInfoFrame(roleByte(advertisedRole), capabilities));
571578
}
572579

573580
byte[] readBuf = new byte[8192];

0 commit comments

Comments
 (0)