Skip to content

Commit 8a1aaf0

Browse files
committed
Add QWP failure-path coverage
1 parent 111f341 commit 8a1aaf0

8 files changed

Lines changed: 533 additions & 34 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/PersistedSymbolDict.java

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,9 @@ public final class PersistedSymbolDict implements QuietCloseable {
172172
// tests inject a fault facade to exercise recovery I/O failures (a truncate
173173
// that cannot drop a torn tail, a short write) without a real broken disk.
174174
private final FilesFacade ff;
175-
// Production writes directly into segmented append mappings. Custom facades retain the
176-
// positioned-write path so fault tests can inject short writes through ff.write.
175+
// Production writes directly into segmented append mappings. Wrapping facades retain the
176+
// positioned-write path by default so fault tests can inject short writes through ff.write;
177+
// mmap-specific fault facades opt in through FilesFacade.isMmapAllowed().
177178
private final boolean mappedAppend;
178179
// True only when recovery parsed the file through a temporary read-only
179180
// mmap instead of allocating a second native buffer as large as the file.
@@ -210,7 +211,7 @@ private PersistedSymbolDict(
210211
) {
211212
this.ff = ff;
212213
this.fd = fd;
213-
this.mappedAppend = ff == FilesFacade.INSTANCE;
214+
this.mappedAppend = ff.isMmapAllowed();
214215
this.mappedRecoveryInput = mappedRecoveryInput;
215216
this.appendOffset = appendOffset;
216217
this.size = size;
@@ -492,7 +493,7 @@ public synchronized void close() {
492493
loadedEntriesLen = 0;
493494
}
494495
if (appendMapAddr != 0L) {
495-
Files.munmap(appendMapAddr, appendMapCapacity, MemoryTag.MMAP_DEFAULT);
496+
ff.munmap(appendMapAddr, appendMapCapacity, MemoryTag.MMAP_DEFAULT);
496497
appendMapAddr = 0L;
497498
appendMapCapacity = 0L;
498499
appendMapOffset = 0L;
@@ -639,13 +640,13 @@ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath,
639640
return null;
640641
}
641642
int len = (int) fileLen; // open() bounds fileLen to [HEADER_SIZE, Integer.MAX_VALUE)
642-
boolean mappedInput = ff == FilesFacade.INSTANCE;
643+
boolean mappedInput = ff.isMmapAllowed();
643644
long inputAddr = 0L;
644645
long entriesAddr = 0L;
645646
int entriesLen = 0;
646647
try {
647648
if (mappedInput) {
648-
inputAddr = Files.mmap(fd, fileLen, 0L, Files.MAP_RO, MemoryTag.MMAP_DEFAULT);
649+
inputAddr = ff.mmap(fd, fileLen, 0L, Files.MAP_RO, MemoryTag.MMAP_DEFAULT);
649650
if (inputAddr == Files.FAILED_MMAP_ADDRESS) {
650651
inputAddr = 0L;
651652
throw new IllegalStateException("could not mmap symbol dictionary for recovery");
@@ -675,7 +676,7 @@ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath,
675676
copyRecoveredEntries(inputAddr, scan.validLen, entriesAddr, entriesLen);
676677
}
677678
if (mappedInput) {
678-
Files.munmap(inputAddr, fileLen, MemoryTag.MMAP_DEFAULT);
679+
ff.munmap(inputAddr, fileLen, MemoryTag.MMAP_DEFAULT);
679680
} else {
680681
Unsafe.free(inputAddr, len, MemoryTag.NATIVE_DEFAULT);
681682
}
@@ -695,7 +696,7 @@ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath,
695696
} catch (Throwable t) {
696697
if (inputAddr != 0L) {
697698
if (mappedInput) {
698-
Files.munmap(inputAddr, fileLen, MemoryTag.MMAP_DEFAULT);
699+
ff.munmap(inputAddr, fileLen, MemoryTag.MMAP_DEFAULT);
699700
} else {
700701
Unsafe.free(inputAddr, len, MemoryTag.NATIVE_DEFAULT);
701702
}
@@ -923,9 +924,9 @@ private void ensureAppendMap(long required) {
923924
appendMapAddr = 0L;
924925
appendMapCapacity = 0L;
925926
appendMapOffset = 0L;
926-
Files.munmap(oldAddr, oldCapacity, MemoryTag.MMAP_DEFAULT);
927+
ff.munmap(oldAddr, oldCapacity, MemoryTag.MMAP_DEFAULT);
927928
}
928-
long newAddr = Files.mmap(
929+
long newAddr = ff.mmap(
929930
fd, newCapacity, newOffset, Files.MAP_RW, MemoryTag.MMAP_DEFAULT);
930931
if (newAddr == Files.FAILED_MMAP_ADDRESS) {
931932
throw new IllegalStateException("could not mmap append region for "

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626

2727
import io.questdb.client.std.Compat;
2828
import io.questdb.client.std.Files;
29+
import io.questdb.client.std.FilesFacade;
2930
import io.questdb.client.std.MemoryTag;
3031
import io.questdb.client.std.QuietCloseable;
3132
import io.questdb.client.std.Unsafe;
33+
import org.jetbrains.annotations.TestOnly;
3234

3335
import java.nio.charset.StandardCharsets;
3436
import java.nio.file.Path;
@@ -65,10 +67,12 @@ public final class SlotLock implements QuietCloseable {
6567
private static final String LOCK_FILE_NAME = ".lock";
6668
private static final String LOCK_PID_FILE_NAME = ".lock.pid";
6769
private static final String LOGICAL_LOCK_DIR_NAME = ".slot-locks";
70+
private final FilesFacade ff;
6871
private final String slotDir;
6972
private int fd;
7073

71-
private SlotLock(String slotDir, int fd) {
74+
private SlotLock(FilesFacade ff, String slotDir, int fd) {
75+
this.ff = ff;
7276
this.slotDir = slotDir;
7377
this.fd = fd;
7478
}
@@ -83,10 +87,10 @@ private SlotLock(String slotDir, int fd) {
8387
*/
8488
public static SlotLock acquire(String slotDir) {
8589
validateSlotDir(slotDir);
86-
ensureDirectory(slotDir, "slot dir");
90+
ensureDirectory(FilesFacade.INSTANCE, slotDir, "slot dir");
8791
String lockPath = slotDir + "/" + LOCK_FILE_NAME;
8892
String pidPath = slotDir + "/" + LOCK_PID_FILE_NAME;
89-
return acquireAt(slotDir, lockPath, pidPath);
93+
return acquireAt(FilesFacade.INSTANCE, slotDir, lockPath, pidPath);
9094
}
9195

9296
/**
@@ -102,6 +106,12 @@ public static SlotLock acquire(String slotDir) {
102106
* the fresh directory through the old pathname.
103107
*/
104108
public static SlotLock acquireLogical(String slotDir) {
109+
return acquireLogical(FilesFacade.INSTANCE, slotDir);
110+
}
111+
112+
/** Facade-aware variant used to exercise logical-lock I/O failures. */
113+
@TestOnly
114+
public static SlotLock acquireLogical(FilesFacade ff, String slotDir) {
105115
validateSlotDir(slotDir);
106116
Path slotPath = Paths.get(slotDir);
107117
Path parentPath = slotPath.getParent();
@@ -113,44 +123,44 @@ public static SlotLock acquireLogical(String slotDir) {
113123
String parentDir = parentPath.toString();
114124
String slotName = slotNamePath.toString();
115125
String logicalLockDir = parentDir + "/" + LOGICAL_LOCK_DIR_NAME;
116-
ensureDirectory(logicalLockDir, "logical slot lock dir");
126+
ensureDirectory(ff, logicalLockDir, "logical slot lock dir");
117127
String lockPath = logicalLockDir + "/" + slotName + ".lock";
118128
String pidPath = logicalLockDir + "/" + slotName + ".lock.pid";
119-
return acquireAt(slotDir, lockPath, pidPath);
129+
return acquireAt(ff, slotDir, lockPath, pidPath);
120130
}
121131

122-
private static SlotLock acquireAt(String slotDir, String lockPath, String pidPath) {
123-
int fd = Files.openRW(lockPath);
132+
private static SlotLock acquireAt(FilesFacade ff, String slotDir, String lockPath, String pidPath) {
133+
int fd = ff.openRW(lockPath);
124134
if (fd < 0) {
125135
throw new IllegalStateException(
126136
"could not open slot lock file: " + lockPath);
127137
}
128138
boolean ok = false;
129139
try {
130-
int rc = Files.lock(fd);
140+
int rc = ff.lock(fd);
131141
if (rc != 0) {
132142
String holder = readHolder(pidPath);
133143
throw new IllegalStateException(
134144
"sf slot already in use by another process [slot="
135145
+ slotDir + ", holder=" + holder + "]");
136146
}
137-
writePid(pidPath);
147+
writePid(ff, pidPath);
138148
ok = true;
139-
return new SlotLock(slotDir, fd);
149+
return new SlotLock(ff, slotDir, fd);
140150
} finally {
141151
if (!ok) {
142-
Files.close(fd);
152+
ff.close(fd);
143153
}
144154
}
145155
}
146156

147-
private static void ensureDirectory(String path, String description) {
148-
if (!Files.exists(path)) {
149-
int rc = Files.mkdir(path, Files.DIR_MODE_DEFAULT);
157+
private static void ensureDirectory(FilesFacade ff, String path, String description) {
158+
if (!ff.exists(path)) {
159+
int rc = ff.mkdir(path, Files.DIR_MODE_DEFAULT);
150160
// Multiple senders may create the shared parent lock directory
151161
// concurrently. Treat EEXIST as success, just as the builder does
152162
// for the SF root itself.
153-
if (rc != 0 && !Files.exists(path)) {
163+
if (rc != 0 && !ff.exists(path)) {
154164
throw new IllegalStateException(
155165
"could not create " + description + ": " + path + " rc=" + rc);
156166
}
@@ -174,7 +184,7 @@ public void close() {
174184
// file or the .lock.pid sidecar — a stale PID is harmless (next
175185
// acquirer overwrites .lock.pid on success).
176186
if (fd >= 0) {
177-
Files.close(fd);
187+
ff.close(fd);
178188
fd = -1;
179189
}
180190
}
@@ -204,33 +214,33 @@ private static String readHolder(String pidPath) {
204214
}
205215
}
206216

207-
private static void writePid(String pidPath) {
217+
private static void writePid(FilesFacade ff, String pidPath) {
208218
long pid;
209219
try {
210220
pid = Compat.currentPid();
211221
} catch (Throwable ignored) {
212222
// Diagnostic-only — never block lock acquisition on it.
213223
pid = -1L;
214224
}
215-
int wfd = Files.openRW(pidPath);
225+
int wfd = ff.openRW(pidPath);
216226
if (wfd < 0) {
217227
// Diagnostic-only — never block lock acquisition on it.
218228
return;
219229
}
220230
try {
221-
Files.truncate(wfd, 0L);
231+
ff.truncate(wfd, 0L);
222232
byte[] payload = (pid + "\n").getBytes(StandardCharsets.UTF_8);
223233
long addr = Unsafe.malloc(payload.length, MemoryTag.NATIVE_DEFAULT);
224234
try {
225235
for (int i = 0; i < payload.length; i++) {
226236
Unsafe.getUnsafe().putByte(addr + i, payload[i]);
227237
}
228-
Files.write(wfd, addr, payload.length, 0L);
238+
ff.write(wfd, addr, payload.length, 0L);
229239
} finally {
230240
Unsafe.free(addr, payload.length, MemoryTag.NATIVE_DEFAULT);
231241
}
232242
} finally {
233-
Files.close(wfd);
243+
ff.close(wfd);
234244
}
235245
}
236246
}

core/src/main/java/io/questdb/client/std/FilesFacade.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ public interface FilesFacade {
8585

8686
int fsync(int fd);
8787

88+
/**
89+
* Whether callers should use this facade's mmap path. The production facade
90+
* returns {@code true}; wrapping fault facades retain positioned I/O unless
91+
* they explicitly opt in, preserving their ability to inject short reads and
92+
* writes.
93+
*/
94+
default boolean isMmapAllowed() {
95+
return this == INSTANCE;
96+
}
97+
8898
long length(int fd);
8999

90100
/**
@@ -107,6 +117,19 @@ public interface FilesFacade {
107117

108118
int mkdir(String path, int mode);
109119

120+
/**
121+
* Maps a file region. Kept on the facade so mmap failures can be injected
122+
* without relying on platform-specific filesystem behavior.
123+
*/
124+
default long mmap(int fd, long len, long offset, int flags, int memoryTag) {
125+
return Files.mmap(fd, len, offset, flags, memoryTag);
126+
}
127+
128+
/** Releases a region returned by {@link #mmap(int, long, long, int, int)}. */
129+
default void munmap(long address, long len, int memoryTag) {
130+
Files.munmap(address, len, memoryTag);
131+
}
132+
110133
int openCleanRW(String path);
111134

112135
/**

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,69 @@ public void testReset() throws Exception {
12851285
});
12861286
}
12871287

1288+
@Test
1289+
public void testSplitMessageValidatesArgumentsAndSizeOverflow() throws Exception {
1290+
assertMemoryLeak(() -> {
1291+
try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
1292+
MicrobatchBuffer target = new MicrobatchBuffer(64);
1293+
QwpTableBuffer table = new QwpTableBuffer("alpha")) {
1294+
GlobalSymbolDictionary dict = new GlobalSymbolDictionary();
1295+
int aapl = dict.getOrAddSymbol("AAPL");
1296+
dict.getOrAddSymbol("GOOG");
1297+
table.getOrCreateColumn("sym", TYPE_SYMBOL, false)
1298+
.addSymbolWithGlobalId("AAPL", aapl);
1299+
table.nextRow();
1300+
1301+
encoder.beginMessage(1, dict, -1, 1);
1302+
int tableBodyOffset = encoder.getBuffer().getPosition();
1303+
encoder.addTable(table);
1304+
int tableBodyLength = encoder.getBuffer().getPosition() - tableBodyOffset;
1305+
encoder.finishMessage();
1306+
1307+
target.setBufferPos(1);
1308+
assertFailure(IllegalStateException.class,
1309+
"split message target is not empty",
1310+
() -> encoder.copySplitMessage(
1311+
target, tableBodyOffset, tableBodyLength, false, -1, 1));
1312+
Assert.assertEquals("invalid target must not be modified", 1, target.getBufferPos());
1313+
target.reset();
1314+
1315+
assertFailure(IllegalArgumentException.class,
1316+
"table body slice is outside the staged message",
1317+
() -> encoder.copySplitMessage(
1318+
target, tableBodyOffset - 1, tableBodyLength, false, -1, 1));
1319+
assertFailure(IllegalArgumentException.class,
1320+
"table body slice is outside the staged message",
1321+
() -> encoder.copySplitMessage(
1322+
target, tableBodyOffset, -1, false, -1, 1));
1323+
assertFailure(IllegalArgumentException.class,
1324+
"table body slice is outside the staged message",
1325+
() -> encoder.copySplitMessage(
1326+
target, tableBodyOffset, tableBodyLength + 1, false, -1, 1));
1327+
Assert.assertEquals("invalid slices must not write the target", 0, target.getBufferPos());
1328+
1329+
assertFailure(IllegalStateException.class,
1330+
"split delta does not match the staged message"
1331+
+ " [stagedStart=0, stagedCount=2, splitStart=1, splitCount=1]",
1332+
() -> encoder.copySplitMessage(
1333+
target, tableBodyOffset, tableBodyLength, false, 0, 1));
1334+
Assert.assertEquals("delta mismatch must not write the target", 0, target.getBufferPos());
1335+
1336+
assertFailure(IllegalArgumentException.class,
1337+
"tableBodyLength must be non-negative",
1338+
() -> encoder.getSplitMessageSize(-1, -1, 1));
1339+
long overflowSize = (long) HEADER_SIZE
1340+
+ 1 // delta start 0
1341+
+ 1 // delta count 2
1342+
+ encoder.getDeltaEntriesLen()
1343+
+ Integer.MAX_VALUE;
1344+
assertFailure(OutOfMemoryError.class,
1345+
"split QWP message size overflow: " + overflowSize,
1346+
() -> encoder.getSplitMessageSize(Integer.MAX_VALUE, -1, 1));
1347+
}
1348+
});
1349+
}
1350+
12881351
@Test
12891352
public void testSplitMessageCopiesStagedTableBodies() throws Exception {
12901353
assertMemoryLeak(() -> {
@@ -1388,6 +1451,22 @@ public void testVersionByteInHeader() throws Exception {
13881451
});
13891452
}
13901453

1454+
private static void assertFailure(
1455+
Class<? extends Throwable> expectedType,
1456+
String expectedMessage,
1457+
Runnable action
1458+
) {
1459+
Throwable thrown = null;
1460+
try {
1461+
action.run();
1462+
} catch (Throwable t) {
1463+
thrown = t;
1464+
}
1465+
Assert.assertNotNull("expected " + expectedType.getSimpleName(), thrown);
1466+
Assert.assertEquals(expectedType, thrown.getClass());
1467+
Assert.assertEquals(expectedMessage, thrown.getMessage());
1468+
}
1469+
13911470
private static final class Cursor {
13921471
private long address;
13931472

0 commit comments

Comments
 (0)