Skip to content

Commit 111f341

Browse files
committed
Add QWP edge-case coverage
1 parent 4386ca1 commit 111f341

5 files changed

Lines changed: 176 additions & 3 deletions

File tree

core/src/main/java/io/questdb/client/Sender.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
import io.questdb.client.std.Decimal256;
5353
import io.questdb.client.std.Decimal64;
5454
import io.questdb.client.std.Files;
55+
import io.questdb.client.std.FilesFacade;
5556
import io.questdb.client.std.IntList;
5657
import io.questdb.client.std.Numbers;
5758
import io.questdb.client.std.NumericException;
@@ -1012,6 +1013,8 @@ final class LineSenderBuilder {
10121013
private static final int PROTOCOL_WEBSOCKET = 2;
10131014
@TestOnly
10141015
private static volatile Runnable quarantineAfterCloseHook;
1016+
@TestOnly
1017+
private static volatile FilesFacade quarantineFilesFacade = FilesFacade.INSTANCE;
10151018
// Suffix for a slot set aside by quarantineTornSlot. Deliberately NOT the
10161019
// sender's own slot name, so a restarted sender does not re-adopt it as its own;
10171020
// quarantineTornSlot then marks it .failed, so the orphan drainer skips it too and
@@ -3061,15 +3064,16 @@ private static CursorSendEngine quarantineTornSlot(
30613064
hook.run();
30623065
}
30633066

3067+
FilesFacade ff = quarantineFilesFacade;
30643068
String quarantinePath = null;
30653069
for (int i = 0; i < MAX_QUARANTINE_SLOT_ATTEMPTS; i++) {
30663070
String candidate = sfDir + "/" + senderId + QUARANTINE_SLOT_SUFFIX + i;
3067-
if (!Files.exists(candidate)) {
3071+
if (!ff.exists(candidate)) {
30683072
quarantinePath = candidate;
30693073
break;
30703074
}
30713075
}
3072-
if (quarantinePath == null || Files.rename(slotPath, quarantinePath) != 0) {
3076+
if (quarantinePath == null || ff.rename(slotPath, quarantinePath) != 0) {
30733077
throw new LineSenderException(
30743078
detail + "; the affected data must be resent. The slot could not be set aside "
30753079
+ "automatically (" + (quarantinePath == null
@@ -3092,6 +3096,11 @@ public static void setQuarantineAfterCloseHookForTest(Runnable hook) {
30923096
quarantineAfterCloseHook = hook;
30933097
}
30943098

3099+
@TestOnly
3100+
public static void setQuarantineFilesFacadeForTest(FilesFacade ff) {
3101+
quarantineFilesFacade = ff == null ? FilesFacade.INSTANCE : ff;
3102+
}
3103+
30953104
private static int resolveIPv4(String host) {
30963105
try {
30973106
byte[] addr = InetAddress.getByName(host).getAddress();
@@ -4047,7 +4056,10 @@ public java.util.Map<String, Object> wsConfigSnapshotForTest() {
40474056
m.put("max_background_drainers", maxBackgroundDrainers);
40484057
m.put("max_frame_rejections", maxFrameRejections);
40494058
m.put("poison_min_escalation_window_millis", poisonMinEscalationWindowMillis);
4050-
m.put("catchup_cap_gap_min_escalation_window_millis", catchUpCapGapMinEscalationWindowMillis);
4059+
m.put("catchup_cap_gap_min_escalation_window_millis",
4060+
catchUpCapGapMinEscalationWindowMillis == PARAMETER_NOT_SET_EXPLICITLY
4061+
? CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS
4062+
: catchUpCapGapMinEscalationWindowMillis);
40514063
m.put("error_inbox_capacity", errorInboxCapacity);
40524064
m.put("connection_listener_inbox_capacity", connectionListenerInboxCapacity);
40534065
m.put("token", httpToken);

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictRecoveryTest.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,53 @@ public void testQuarantineFailsLoudlyWhenAllSlotNamesSaturated() throws Exceptio
467467
});
468468
}
469469

470+
@Test
471+
public void testQuarantineRenameFailurePreservesOriginalSlot() throws Exception {
472+
assertMemoryLeak(() -> {
473+
writeAndTearUnreplayableSlot();
474+
java.nio.file.Path slot = Paths.get(sfDir, "default");
475+
int segmentCount = countSegmentFiles(slot);
476+
Sender.LineSenderBuilder.setQuarantineFilesFacadeForTest(new DelegatingFilesFacade() {
477+
@Override
478+
public int rename(String oldPath, String newPath) {
479+
return -1;
480+
}
481+
});
482+
try {
483+
try (TestWebSocketServer good = new TestWebSocketServer(new DictReconstructingHandler())) {
484+
int port = good.getPort();
485+
good.start();
486+
Assert.assertTrue(good.awaitStart(5, TimeUnit.SECONDS));
487+
String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";";
488+
try {
489+
Sender.fromConfig(cfg).close();
490+
Assert.fail("build() must throw when the unreplayable slot rename fails");
491+
} catch (LineSenderException expected) {
492+
Assert.assertEquals(
493+
"recovered store-and-forward symbol dictionary is incomplete and cannot be rebuilt "
494+
+ "from the surviving frames (likely a host crash tore its unsynced tail): "
495+
+ "the frames reference symbol ids below their own delta start, which were "
496+
+ "introduced by frames since acked and trimmed away, so nothing still holds "
497+
+ "them; the recovered dictionary holds only 0 id(s) -- resend the affected "
498+
+ "data; the affected data must be resent. The slot could not be set aside "
499+
+ "automatically (rename to " + sfDir + "/default.unreplayable-0 failed), so "
500+
+ "this sender cannot start until " + sfDir + "/default is moved or removed "
501+
+ "by hand",
502+
expected.getMessage());
503+
}
504+
}
505+
} finally {
506+
Sender.LineSenderBuilder.setQuarantineFilesFacadeForTest(null);
507+
}
508+
Assert.assertTrue("rename failure must preserve the original slot directory",
509+
java.nio.file.Files.isDirectory(slot));
510+
Assert.assertEquals("rename failure must preserve every segment",
511+
segmentCount, countSegmentFiles(slot));
512+
Assert.assertFalse("failed rename must not leave a quarantine directory",
513+
java.nio.file.Files.exists(Paths.get(sfDir, "default.unreplayable-0")));
514+
});
515+
}
516+
470517
// Writes 12 delta frames (each introducing a new symbol) into the default slot
471518
// across several small segments, then makes the slot GENUINELY unreplayable: trims
472519
// the segment holding the earliest ids (munmap + unlink, exactly what SegmentManager

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/GlobalSymbolDictionaryTest.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ public void testAddRecoveredSymbol_appendsWithoutDeduplicating() {
6161
assertEquals(3, recovered.getOrAddSymbol("brand-new"));
6262
}
6363

64+
@Test
65+
public void testAddRecoveredSymbol_rejectsNullWithoutMutation() {
66+
GlobalSymbolDictionary dict = new GlobalSymbolDictionary();
67+
assertEquals(0, dict.addRecoveredSymbol("AAPL"));
68+
69+
try {
70+
dict.addRecoveredSymbol(null);
71+
fail("expected IllegalArgumentException");
72+
} catch (IllegalArgumentException expected) {
73+
assertEquals("symbol cannot be null", expected.getMessage());
74+
}
75+
76+
assertEquals(1, dict.size());
77+
assertEquals("AAPL", dict.getSymbol(0));
78+
assertEquals(0, dict.getId("AAPL"));
79+
}
80+
6481
@Test
6582
public void testAddSymbol_assignsSequentialIds() {
6683
GlobalSymbolDictionary dict = new GlobalSymbolDictionary();

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,49 @@ public void testBuilderWithWebSocketTransportCreatesCorrectSenderType() throws E
222222
});
223223
}
224224

225+
@Test
226+
public void testCatchUpCapGapMinEscalationWindowBuilderNegativeRejected() {
227+
assertThrows("catchup_cap_gap_min_escalation_window_millis must be >= 0: -1",
228+
() -> Sender.builder(Sender.Transport.WEBSOCKET)
229+
.address(LOCALHOST)
230+
.catchUpCapGapMinEscalationWindowMillis(-1));
231+
}
232+
233+
@Test
234+
public void testCatchUpCapGapMinEscalationWindowBuilderNotSupportedForTcp() {
235+
assertThrows("catchup_cap_gap_min_escalation_window_millis is only supported for WebSocket transport",
236+
() -> Sender.builder(Sender.Transport.TCP)
237+
.address(LOCALHOST)
238+
.catchUpCapGapMinEscalationWindowMillis(300_000));
239+
}
240+
241+
@Test
242+
public void testCatchUpCapGapMinEscalationWindowConfigStringNegativeRejected() {
243+
assertThrows("catchup_cap_gap_min_escalation_window_millis must be >= 0: -1",
244+
() -> Sender.builder("ws::addr=localhost:9000;"
245+
+ "catchup_cap_gap_min_escalation_window_millis=-1;"));
246+
}
247+
248+
@Test
249+
public void testCatchUpCapGapMinEscalationWindowConfigStringNotSupportedForTcp() {
250+
assertThrows("catchup_cap_gap_min_escalation_window_millis is only supported for WebSocket transport",
251+
() -> Sender.builder("tcp::addr=localhost:9009;"
252+
+ "catchup_cap_gap_min_escalation_window_millis=300000;"));
253+
}
254+
255+
@Test
256+
public void testCatchUpCapGapMinEscalationWindowDefaultsToFiveMinutes() {
257+
Assert.assertEquals(300_000L,
258+
Sender.builder(Sender.Transport.WEBSOCKET)
259+
.address(LOCALHOST)
260+
.wsConfigSnapshotForTest()
261+
.get("catchup_cap_gap_min_escalation_window_millis"));
262+
Assert.assertEquals(300_000L,
263+
Sender.builder("ws::addr=localhost:9000;")
264+
.wsConfigSnapshotForTest()
265+
.get("catchup_cap_gap_min_escalation_window_millis"));
266+
}
267+
225268
@Test
226269
public void testConnectionRefused() throws Exception {
227270
assertMemoryLeak(() -> {

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/NativeBufferWriterTest.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,43 @@ public void testQwpBufferWriterUtf8LengthInvalidSurrogatePair() throws Exception
273273
});
274274
}
275275

276+
@Test
277+
public void testRawVarintBoundariesAndReturnedAddress() throws Exception {
278+
assertMemoryLeak(() -> {
279+
try (NativeBufferWriter writer = new NativeBufferWriter(16)) {
280+
assertRawVarint(writer, 0, 0x00);
281+
assertRawVarint(writer, 1, 0x01);
282+
assertRawVarint(writer, 127, 0x7F);
283+
assertRawVarint(writer, 128, 0x80, 0x01);
284+
assertRawVarint(writer, 16_383, 0xFF, 0x7F);
285+
assertRawVarint(writer, 16_384, 0x80, 0x80, 0x01);
286+
assertRawVarint(writer, Long.MAX_VALUE,
287+
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F);
288+
}
289+
});
290+
}
291+
292+
@Test
293+
public void testRawVarintRejectsNegativeWithoutWriting() throws Exception {
294+
assertMemoryLeak(() -> {
295+
try (NativeBufferWriter writer = new NativeBufferWriter(16)) {
296+
long addr = writer.getBufferPtr();
297+
Unsafe.getUnsafe().putByte(addr, (byte) 0x5A);
298+
boolean rejected = false;
299+
try {
300+
NativeBufferWriter.writeVarint(addr, -1);
301+
} catch (AssertionError expected) {
302+
rejected = true;
303+
assertTrue(expected.getMessage().contains(
304+
"unsigned LEB128 varint requires a non-negative value: -1"));
305+
}
306+
assertTrue("negative raw varint must be rejected", rejected);
307+
assertEquals("rejection must happen before the first write",
308+
(byte) 0x5A, Unsafe.getUnsafe().getByte(addr));
309+
}
310+
});
311+
}
312+
276313
@Test
277314
public void testReset() throws Exception {
278315
assertMemoryLeak(() -> {
@@ -618,4 +655,21 @@ public void testWriteVarintSmall() throws Exception {
618655
}
619656
});
620657
}
658+
659+
private static void assertRawVarint(NativeBufferWriter writer, long value, int... expectedBytes) {
660+
long addr = writer.getBufferPtr();
661+
for (int i = 0; i <= expectedBytes.length; i++) {
662+
Unsafe.getUnsafe().putByte(addr + i, (byte) 0x5A);
663+
}
664+
long end = NativeBufferWriter.writeVarint(addr, value);
665+
assertEquals("returned address for value=" + value, addr + expectedBytes.length, end);
666+
assertEquals("varintSize for value=" + value,
667+
expectedBytes.length, NativeBufferWriter.varintSize(value));
668+
for (int i = 0; i < expectedBytes.length; i++) {
669+
assertEquals("byte " + i + " for value=" + value,
670+
(byte) expectedBytes[i], Unsafe.getUnsafe().getByte(addr + i));
671+
}
672+
assertEquals("writer overran value=" + value,
673+
(byte) 0x5A, Unsafe.getUnsafe().getByte(end));
674+
}
621675
}

0 commit comments

Comments
 (0)