Skip to content

Commit 265b703

Browse files
glasstigerclaude
andcommitted
Harden the symbol-dict fd close and split sizing
Three review follow-ups on the delta symbol-dictionary change: - PersistedSymbolDict relinquishes fd (sets it to -1) before each in-branch ff.close and guards the catch-block closes with fd >= 0, so a throwing in-branch close cannot double-close via the catch. Completes the null-before-free discipline the buffers already use. - QwpWebSocketEncoderTest pins the split path's context-free-body invariant: a table's body bytes must encode identically solo and combined, since flushPendingRowsSplit sizes each frame from the combined encode's splitFrameBodyBytes rather than re-measuring. A future column encoder that carried cross-table state now fails here instead of silently stranding a deferred prefix. - CursorSendEngine imports QwpConstants instead of spelling it out fully-qualified eight times, matching its sibling send loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 39e9e52 commit 265b703

3 files changed

Lines changed: 92 additions & 13 deletions

File tree

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
package io.questdb.client.cutlass.qwp.client.sf.cursor;
2626

27+
import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
2728
import io.questdb.client.std.Compat;
2829
import io.questdb.client.std.Files;
2930
import io.questdb.client.std.ObjList;
@@ -307,10 +308,10 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
307308
// commit would resurrect half a transaction -- see the WARN
308309
// below. Computed before the I/O loop or producer append.
309310
this.recoveredCommitBoundaryFsn = recovered.findLastFsnWithoutPayloadFlag(
310-
io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_OFFSET_FLAGS,
311-
io.questdb.client.cutlass.qwp.protocol.QwpConstants.FLAG_DEFER_COMMIT,
312-
io.questdb.client.cutlass.qwp.protocol.QwpConstants.MAGIC_MESSAGE,
313-
io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE
311+
QwpConstants.HEADER_OFFSET_FLAGS,
312+
QwpConstants.FLAG_DEFER_COMMIT,
313+
QwpConstants.MAGIC_MESSAGE,
314+
QwpConstants.HEADER_SIZE
314315
);
315316
if (publishedFsn >= 0 && recoveredCommitBoundaryFsn < publishedFsn) {
316317
this.recoveredOrphanTipFsn = publishedFsn;
@@ -332,10 +333,10 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
332333
// returns 0 when no such frame carries a symbol, yielding -1 here.
333334
// Computed before the I/O loop or producer append; single-threaded.
334335
this.recoveredMaxSymbolId = recovered.maxSymbolDeltaEnd(
335-
io.questdb.client.cutlass.qwp.protocol.QwpConstants.MAGIC_MESSAGE,
336-
io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_OFFSET_FLAGS,
337-
io.questdb.client.cutlass.qwp.protocol.QwpConstants.FLAG_DELTA_SYMBOL_DICT,
338-
io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE,
336+
QwpConstants.MAGIC_MESSAGE,
337+
QwpConstants.HEADER_OFFSET_FLAGS,
338+
QwpConstants.FLAG_DELTA_SYMBOL_DICT,
339+
QwpConstants.HEADER_SIZE,
339340
recoveredCommitBoundaryFsn) - 1L;
340341
} else {
341342
// Fresh start with no recovered segments. Any stale

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,9 @@ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath,
532532
LOG.warn("symbol dict {} unreadable, bad magic or unknown version; recreating", filePath);
533533
Unsafe.free(buf, len, MemoryTag.NATIVE_DEFAULT);
534534
buf = 0L; // null after free so the catch below cannot double-free if ff.close throws
535-
ff.close(fd);
535+
int fdToClose = fd;
536+
fd = -1; // relinquish before close so the catch cannot double-close if close throws
537+
ff.close(fdToClose);
536538
return null;
537539
}
538540
// Parse complete, CRC-valid entries after the header; stop at the first
@@ -622,7 +624,9 @@ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath,
622624
entriesAddr = 0L;
623625
entriesLen = 0;
624626
}
625-
ff.close(fd);
627+
int fdToClose = fd;
628+
fd = -1; // relinquish before close so the catch cannot double-close if close throws
629+
ff.close(fdToClose);
626630
return null;
627631
}
628632
return new PersistedSymbolDict(ff, fd, validLen, count, entriesAddr, entriesLen);
@@ -638,7 +642,9 @@ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath,
638642
if (entriesAddr != 0L) {
639643
Unsafe.free(entriesAddr, entriesLen, MemoryTag.NATIVE_DEFAULT);
640644
}
641-
ff.close(fd);
645+
if (fd >= 0) { // a branch that already closed fd relinquished it to -1
646+
ff.close(fd);
647+
}
642648
LOG.warn("symbol dict {} recovery failed ({}); recreating", filePath, String.valueOf(t));
643649
return null;
644650
}
@@ -660,7 +666,9 @@ private static PersistedSymbolDict openFresh(FilesFacade ff, String filePath) {
660666
Unsafe.getUnsafe().putByte(hdr + 7, (byte) 0);
661667
long written = ff.write(fd, hdr, HEADER_SIZE, 0);
662668
if (written != HEADER_SIZE) {
663-
ff.close(fd);
669+
int fdToClose = fd;
670+
fd = -1; // relinquish before close so the catch cannot double-close if close throws
671+
ff.close(fdToClose);
664672
ff.remove(filePath);
665673
LOG.warn("symbol dict {} header write failed; proceeding without it", filePath);
666674
return null;
@@ -670,7 +678,9 @@ private static PersistedSymbolDict openFresh(FilesFacade ff, String filePath) {
670678
// throwing; the Unsafe puts target a valid 8-byte buffer and an 8-byte
671679
// malloc cannot realistically OOM), but close the fd against a future
672680
// edit so it cannot leak -- mirroring openExisting's error handling.
673-
ff.close(fd);
681+
if (fd >= 0) { // the header-write branch relinquished fd to -1 before closing
682+
ff.close(fd);
683+
}
674684
LOG.warn("symbol dict {} creation failed ({}); proceeding without it", filePath, String.valueOf(t));
675685
return null;
676686
} finally {

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,6 +1284,74 @@ public void testReset() throws Exception {
12841284
});
12851285
}
12861286

1287+
@Test
1288+
public void testTableBodyEncodingIsContextFree() throws Exception {
1289+
// The split-flush path (QwpWebSocketSender.flushPendingRowsSplit) sizes each
1290+
// per-table frame ARITHMETICALLY from splitFrameBodyBytes -- the body byte
1291+
// count captured during the COMBINED encode in flushPendingRows -- instead of
1292+
// re-encoding to measure. That is sound only while a table's body bytes are
1293+
// context-free: identical whether the table is encoded solo or as the k-th
1294+
// table after other tables and the delta section. Today every column encoder
1295+
// is stateless and symbol cells carry absolute global ids, so the property
1296+
// holds; this pins it so a future column encoder that ever carried
1297+
// cross-table state (which would break the arithmetic sizing and could strand
1298+
// a deferred prefix mid-split) fails loudly here rather than silently in
1299+
// production.
1300+
assertMemoryLeak(() -> {
1301+
try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
1302+
QwpTableBuffer t0 = new QwpTableBuffer("alpha");
1303+
QwpTableBuffer t1 = new QwpTableBuffer("bravo");
1304+
QwpTableBuffer t2 = new QwpTableBuffer("charlie")) {
1305+
GlobalSymbolDictionary dict = new GlobalSymbolDictionary();
1306+
int aapl = dict.getOrAddSymbol("AAPL"); // 0
1307+
int goog = dict.getOrAddSymbol("GOOG"); // 1
1308+
int msft = dict.getOrAddSymbol("MSFT"); // 2
1309+
1310+
// Distinct schemas + a symbol column (absolute global ids) so a
1311+
// positional / cross-table encoder bug would shift the body bytes.
1312+
t0.getOrCreateColumn("sym", TYPE_SYMBOL, false).addSymbolWithGlobalId("AAPL", aapl);
1313+
t0.getOrCreateColumn("v", TYPE_LONG, false).addLong(1L);
1314+
t0.nextRow();
1315+
1316+
t1.getOrCreateColumn("sym", TYPE_SYMBOL, false).addSymbolWithGlobalId("GOOG", goog);
1317+
t1.getOrCreateColumn("d", TYPE_DOUBLE, false).addDouble(2.5);
1318+
t1.getOrCreateColumn("s", TYPE_VARCHAR, true).addString("hello");
1319+
t1.nextRow();
1320+
1321+
t2.getOrCreateColumn("sym", TYPE_SYMBOL, false).addSymbolWithGlobalId("MSFT", msft);
1322+
t2.getOrCreateDesignatedTimestampColumn(TYPE_TIMESTAMP).addLong(1_000_000L);
1323+
t2.nextRow();
1324+
1325+
QwpTableBuffer[] tables = {t0, t1, t2};
1326+
int confirmedMaxId = -1;
1327+
int batchMaxId = 2;
1328+
1329+
// Combined encode: capture each table's body bytes exactly as
1330+
// flushPendingRows' splitFrameBodyBytes does (position delta per addTable).
1331+
int[] combinedBody = new int[tables.length];
1332+
encoder.beginMessage(tables.length, dict, confirmedMaxId, batchMaxId);
1333+
int bodyStart = encoder.getBuffer().getPosition();
1334+
for (int i = 0; i < tables.length; i++) {
1335+
encoder.addTable(tables[i]);
1336+
int bodyEnd = encoder.getBuffer().getPosition();
1337+
combinedBody[i] = bodyEnd - bodyStart;
1338+
bodyStart = bodyEnd;
1339+
}
1340+
1341+
// Solo encode each table under the SAME baseline/batch max, as the
1342+
// split publish loop does, and assert the body bytes match the capture.
1343+
for (int i = 0; i < tables.length; i++) {
1344+
encoder.beginMessage(1, dict, confirmedMaxId, batchMaxId);
1345+
int soloBodyStart = encoder.getBuffer().getPosition();
1346+
encoder.addTable(tables[i]);
1347+
int soloBody = encoder.getBuffer().getPosition() - soloBodyStart;
1348+
Assert.assertEquals("table " + i + " body must encode identically solo vs combined "
1349+
+ "(splitFrameBodyBytes relies on a context-free body)", combinedBody[i], soloBody);
1350+
}
1351+
}
1352+
});
1353+
}
1354+
12871355
@Test
12881356
public void testVersionByteInHeader() throws Exception {
12891357
assertMemoryLeak(() -> {

0 commit comments

Comments
 (0)