Skip to content

Commit 0ef96af

Browse files
glasstigerclaude
andcommitted
Cover the reconnect catch-up ACK alignment
setWireBaselineWithCatchUp anchors fsnAtZero = replayStart - catchUpFrames so every catch-up frame maps to an already-acked FSN. Dropping the - catchUpFrames term is silent data loss: a server ACK for a catch-up frame then translates to an FSN at or above replayStart and trims a not-yet-delivered data frame from the store-and-forward log. The existing catch-up tests reconstruct the dictionary from wire bytes and never assert ACK/trim accounting, so they were blind to this line; the enterprise SqlFailoverQwpClientLosslessTest ingests no symbols and never enters the catch-up path at all. CursorWebSocketSendLoopCatchUpAlignmentTest drives the catch-up against a stub client and asserts the catch-up frame's OK leaves the real engine's ackedFsn untouched, for both a single catch-up frame and a split (multi-frame) catch-up. Reverting the - catchUpFrames term fails both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9602440 commit 0ef96af

1 file changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
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.sf.cursor;
26+
27+
import io.questdb.client.DefaultHttpClientConfiguration;
28+
import io.questdb.client.cutlass.http.client.WebSocketClient;
29+
import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
30+
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
31+
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
32+
import io.questdb.client.network.PlainSocketFactory;
33+
import io.questdb.client.std.Files;
34+
import io.questdb.client.std.MemoryTag;
35+
import io.questdb.client.std.Unsafe;
36+
import io.questdb.client.test.tools.TestUtils;
37+
import org.junit.After;
38+
import org.junit.Before;
39+
import org.junit.Test;
40+
41+
import java.lang.reflect.Field;
42+
import java.lang.reflect.Method;
43+
import java.nio.charset.StandardCharsets;
44+
import java.nio.file.Paths;
45+
46+
import static org.junit.Assert.assertEquals;
47+
48+
/**
49+
* Guards the reconnect/failover symbol-dictionary catch-up ACK alignment in
50+
* {@link CursorWebSocketSendLoop#setWireBaselineWithCatchUp}.
51+
* <p>
52+
* On a fresh connection the loop re-registers the whole dictionary with a
53+
* catch-up frame BEFORE replaying data frames. Each catch-up frame consumes a
54+
* wire sequence, so the loop anchors {@code fsnAtZero = replayStart - catchUpFrames}
55+
* to keep every catch-up frame mapped to an already-acked FSN. Dropping the
56+
* {@code - catchUpFrames} term is silent data loss: a server ACK for a catch-up
57+
* frame then translates through {@code engine.acknowledge(fsnAtZero + wireSeq)}
58+
* to an FSN at or above {@code replayStart}, trimming a not-yet-delivered data
59+
* frame from the store-and-forward log.
60+
* <p>
61+
* The loop is constructed but never {@link CursorWebSocketSendLoop#start started};
62+
* the catch-up runs against a stub {@link WebSocketClient} that counts frames, and
63+
* the OK is delivered straight into the inner {@code ResponseHandler} -- the same
64+
* white-box idiom {@code CursorWebSocketSendLoopDurableAckTest} uses, because
65+
* {@code setWireBaselineWithCatchUp} and the wire ports have no public entry point.
66+
* {@link CursorSendEngine#ackedFsn()} is the authoritative trim watermark asserted
67+
* against.
68+
*/
69+
public class CursorWebSocketSendLoopCatchUpAlignmentTest {
70+
71+
private String tmpDir;
72+
73+
@Before
74+
public void setUp() {
75+
tmpDir = Paths.get(System.getProperty("java.io.tmpdir"),
76+
"qdb-cursor-catchup-" + System.nanoTime()).toString();
77+
assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT));
78+
}
79+
80+
@After
81+
public void tearDown() {
82+
if (tmpDir == null) return;
83+
long find = Files.findFirst(tmpDir);
84+
if (find > 0) {
85+
try {
86+
int rc = 1;
87+
while (rc > 0) {
88+
String name = Files.utf8ToString(Files.findName(find));
89+
if (name != null && !".".equals(name) && !"..".equals(name)) {
90+
Files.remove(tmpDir + "/" + name);
91+
}
92+
rc = Files.findNext(find);
93+
}
94+
} finally {
95+
Files.findClose(find);
96+
}
97+
}
98+
Files.remove(tmpDir);
99+
}
100+
101+
@Test
102+
public void testCatchUpFrameAckDoesNotAdvanceTrimWatermark() throws Exception {
103+
// Single catch-up frame (server advertises no cap). Two frames were
104+
// acked before the reconnect (ackedFsn=1), FSN 2 is unacked. The catch-up
105+
// frame's OK must NOT advance the watermark past 1 -- it carries no data,
106+
// only the dictionary the fresh server needs before replay.
107+
TestUtils.assertMemoryLeak(() -> {
108+
CatchUpCapturingClient client = new CatchUpCapturingClient(0); // 0 => no cap => one frame
109+
try (CursorSendEngine engine = newEngine()) {
110+
appendFrames(engine, 3); // FSN 0,1,2 published
111+
engine.acknowledge(1); // ackedFsn=1 => replayStart=2, FSN 2 still unacked
112+
CursorWebSocketSendLoop loop = newLoop(engine, client);
113+
try {
114+
seedMirror(loop, "s0", "s1"); // sentDictCount=2 => catch-up fires
115+
long replayStart = engine.ackedFsn() + 1L; // = 2
116+
117+
invokeSetWireBaselineWithCatchUp(loop, replayStart);
118+
119+
assertEquals("whole dictionary fits one frame under no cap",
120+
1, client.framesSent);
121+
122+
// Behavioural (the harm): the catch-up frame (wire seq 0) is
123+
// OK'd by the fresh server. It carries no data, so it must
124+
// resolve to an already-acked FSN and leave the trim watermark
125+
// untouched -- advancing it would trim the undelivered FSN 2.
126+
deliverOk(loop, 0);
127+
assertEquals("catch-up frame ACK must not advance the trim watermark "
128+
+ "(would trim an undelivered data frame -> silent data loss)",
129+
1L, engine.ackedFsn());
130+
// Mechanism: the catch-up frames are anchored below replayStart.
131+
assertEquals("fsnAtZero must be anchored catchUpFrames below replayStart",
132+
replayStart - client.framesSent, readLong(loop, "fsnAtZero"));
133+
} finally {
134+
loop.close(); // frees the seeded mirror + the stub client's buffers
135+
}
136+
}
137+
});
138+
}
139+
140+
@Test
141+
public void testSplitCatchUpFramesAcksDoNotAdvanceTrimWatermark() throws Exception {
142+
// A small advertised cap splits the dictionary across several catch-up
143+
// frames, so the fsnAtZero offset must subtract the full frame count. Ack
144+
// the LAST catch-up wire sequence: it still maps below replayStart. With
145+
// the offset dropped it would translate to replayStart+1 and over-trim.
146+
TestUtils.assertMemoryLeak(() -> {
147+
CatchUpCapturingClient client = new CatchUpCapturingClient(40); // budget 12 => one 11-byte symbol per frame
148+
try (CursorSendEngine engine = newEngine()) {
149+
appendFrames(engine, 5); // FSN 0..4 published
150+
engine.acknowledge(2); // ackedFsn=2 => replayStart=3, FSN 3,4 unacked
151+
CursorWebSocketSendLoop loop = newLoop(engine, client);
152+
try {
153+
seedMirror(loop, "symbol0000", "symbol0001"); // 11 bytes each -> two frames
154+
long replayStart = engine.ackedFsn() + 1L; // = 3
155+
156+
invokeSetWireBaselineWithCatchUp(loop, replayStart);
157+
158+
assertEquals("cap must split the two symbols across two frames",
159+
2, client.framesSent);
160+
161+
// ACK the highest catch-up wire sequence (the last catch-up
162+
// frame). It too must map below replayStart -- with the offset
163+
// dropped it translates to replayStart+1 and over-trims.
164+
deliverOk(loop, client.framesSent - 1);
165+
assertEquals("no catch-up frame ACK may advance the trim watermark",
166+
2L, engine.ackedFsn());
167+
assertEquals("fsnAtZero must subtract the full split frame count",
168+
replayStart - client.framesSent, readLong(loop, "fsnAtZero"));
169+
} finally {
170+
loop.close();
171+
}
172+
}
173+
});
174+
}
175+
176+
private static void appendFrames(CursorSendEngine engine, int count) {
177+
long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
178+
try {
179+
byte[] payload = "frame-bytes-padd".getBytes(StandardCharsets.US_ASCII);
180+
for (int i = 0; i < payload.length; i++) {
181+
Unsafe.getUnsafe().putByte(buf + i, payload[i]);
182+
}
183+
for (int i = 0; i < count; i++) {
184+
engine.appendBlocking(buf, 16);
185+
}
186+
} finally {
187+
Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
188+
}
189+
}
190+
191+
// Delivers a 0-table STATUS_OK for {@code wireSeq} into the loop's response
192+
// handler, mimicking the server acking a catch-up frame (which carries no tables).
193+
private static void deliverOk(CursorWebSocketSendLoop loop, long wireSeq) throws Exception {
194+
int size = 11; // status(1) + sequence(8) + tableCount(2)
195+
long ptr = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
196+
try {
197+
Unsafe.getUnsafe().putByte(ptr, WebSocketResponse.STATUS_OK);
198+
Unsafe.getUnsafe().putLong(ptr + 1, wireSeq);
199+
Unsafe.getUnsafe().putShort(ptr + 9, (short) 0);
200+
Field f = CursorWebSocketSendLoop.class.getDeclaredField("responseHandler");
201+
f.setAccessible(true);
202+
Object handler = f.get(loop);
203+
Method m = handler.getClass().getDeclaredMethod("onBinaryMessage", long.class, int.class);
204+
m.setAccessible(true);
205+
m.invoke(handler, ptr, size);
206+
} finally {
207+
Unsafe.free(ptr, size, MemoryTag.NATIVE_DEFAULT);
208+
}
209+
}
210+
211+
private static void invokeSetWireBaselineWithCatchUp(CursorWebSocketSendLoop loop, long replayStart) throws Exception {
212+
Method m = CursorWebSocketSendLoop.class.getDeclaredMethod("setWireBaselineWithCatchUp", long.class);
213+
m.setAccessible(true);
214+
m.invoke(loop, replayStart);
215+
}
216+
217+
private CursorWebSocketSendLoop newLoop(CursorSendEngine engine, WebSocketClient client) {
218+
return new CursorWebSocketSendLoop(
219+
client, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
220+
() -> {
221+
throw new UnsupportedOperationException("test loop is never started");
222+
},
223+
5_000L, 100L, 5_000L, false);
224+
}
225+
226+
private CursorSendEngine newEngine() {
227+
return new CursorSendEngine(tmpDir, 16384);
228+
}
229+
230+
private static long readLong(CursorWebSocketSendLoop loop, String name) throws Exception {
231+
Field f = CursorWebSocketSendLoop.class.getDeclaredField(name);
232+
f.setAccessible(true);
233+
return f.getLong(loop);
234+
}
235+
236+
// Populates the loop's native sent-dictionary mirror with {@code symbols} in
237+
// the on-wire [len varint][utf8] layout, so setWireBaselineWithCatchUp sees a
238+
// non-empty dictionary to re-register. loop.close() frees it.
239+
private static void seedMirror(CursorWebSocketSendLoop loop, String... symbols) throws Exception {
240+
int total = 0;
241+
for (String s : symbols) {
242+
int len = s.getBytes(StandardCharsets.UTF_8).length;
243+
total += varintSize(len) + len;
244+
}
245+
long addr = Unsafe.malloc(total, MemoryTag.NATIVE_DEFAULT);
246+
long p = addr;
247+
for (String s : symbols) {
248+
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
249+
p = writeVarint(p, bytes.length);
250+
for (byte b : bytes) {
251+
Unsafe.getUnsafe().putByte(p++, b);
252+
}
253+
}
254+
setField(loop, "sentDictBytesAddr", addr);
255+
setIntField(loop, "sentDictBytesCapacity", total);
256+
setIntField(loop, "sentDictBytesLen", total);
257+
setIntField(loop, "sentDictCount", symbols.length);
258+
}
259+
260+
private static void setField(Object target, String name, long value) throws Exception {
261+
Field f = CursorWebSocketSendLoop.class.getDeclaredField(name);
262+
f.setAccessible(true);
263+
f.setLong(target, value);
264+
}
265+
266+
private static void setIntField(Object target, String name, int value) throws Exception {
267+
Field f = CursorWebSocketSendLoop.class.getDeclaredField(name);
268+
f.setAccessible(true);
269+
f.setInt(target, value);
270+
}
271+
272+
private static int varintSize(long value) {
273+
int n = 1;
274+
while (value > 0x7F) {
275+
value >>>= 7;
276+
n++;
277+
}
278+
return n;
279+
}
280+
281+
private static long writeVarint(long addr, long value) {
282+
while (value > 0x7F) {
283+
Unsafe.getUnsafe().putByte(addr++, (byte) ((value & 0x7F) | 0x80));
284+
value >>>= 7;
285+
}
286+
Unsafe.getUnsafe().putByte(addr++, (byte) value);
287+
return addr;
288+
}
289+
290+
// Stub transport: completes no real I/O. getServerMaxBatchSize drives the
291+
// catch-up split; sendBinary counts the frames the catch-up emitted.
292+
private static final class CatchUpCapturingClient extends WebSocketClient {
293+
private final int cap;
294+
private int framesSent;
295+
296+
CatchUpCapturingClient(int cap) {
297+
super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE);
298+
this.cap = cap;
299+
}
300+
301+
@Override
302+
public int getServerMaxBatchSize() {
303+
return cap;
304+
}
305+
306+
@Override
307+
public int getServerQwpVersion() {
308+
return 1;
309+
}
310+
311+
@Override
312+
public void sendBinary(long dataPtr, int length) {
313+
framesSent++;
314+
}
315+
316+
@Override
317+
protected void ioWait(int timeout, int op) {
318+
}
319+
320+
@Override
321+
protected void setupIoWait() {
322+
}
323+
}
324+
}

0 commit comments

Comments
 (0)