Skip to content

Commit 80dabaa

Browse files
jerrinotclaude
andcommitted
Name designated timestamps in QWP auto-create
QWP senders let callers name the designated timestamp column that the server creates when it auto-creates a missing table. The sender API exposes the hint through a Sender.TableOptions flyweight obtained via sender.table(name).tableOptions().designatedTimestamp(col), keeping Sender itself free of flat per-option methods so future create-time hints (partitioning, storage policies) can land beside it. The wire encoding sets header flag 0x20 and appends a per-table TLV options trailer (tag 0x01 = designated timestamp name) plus a u32 length footer after the table blocks. Old servers ignore the unknown flag and the trailing bytes, so frames degrade to the conventional timestamp column name; when no hint is set the emitted bytes stay byte-identical to the legacy encoding, which a golden-bytes test pins. Both the WebSocket and UDP transports carry the trailer, and store-and-forward replays the self-describing frames safely against older servers. The WebSocket client reads the X-QWP-Table-Options capability header during the upgrade and logs one warning when a configured hint reaches a server that does not advertise support. ILP senders reject the API. The hint is sticky per table, validated to 127 UTF-8 bytes, and re-declarable while a table buffer holds no rows, so pooled sender borrowers can re-declare it instead of hitting an unclearable conflict. The pooled sender guards stale handles behind its lease generation. Tests cover trailer layouts and mixed presence, UDP framing, upgrade-header parsing, stale and retained flyweight references, options-only rows, and lease rejection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f47859b commit 80dabaa

17 files changed

Lines changed: 773 additions & 8 deletions

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,25 @@ default Sender shortColumn(CharSequence name, short value) {
742742
*/
743743
Sender table(CharSequence table);
744744

745+
/**
746+
* Returns create-time options for the currently selected table.
747+
* <p>
748+
* Call this after {@link #table(CharSequence)}. QWP senders retain options
749+
* per table for the sender's lifetime. ILP transports cannot express table
750+
* options.
751+
* <p>
752+
* The returned instance is a single per-sender object that this method and
753+
* {@link #table(CharSequence)} re-target at the currently selected table.
754+
* Do not retain the reference across table selections; use it immediately
755+
* and obtain a fresh one after selecting a table.
756+
*
757+
* @return options for the currently selected table
758+
* @throws LineSenderException if the transport is ILP or no table is selected
759+
*/
760+
default TableOptions tableOptions() {
761+
throw new LineSenderException("table options are not supported by ILP");
762+
}
763+
745764
/**
746765
* Add a column with a non-designated timestamp value.
747766
*
@@ -837,6 +856,32 @@ enum SfDurability {
837856
PERIODIC
838857
}
839858

859+
/**
860+
* Create-time options for the table selected by {@link Sender#table(CharSequence)}.
861+
*/
862+
interface TableOptions {
863+
864+
/**
865+
* Sets the designated timestamp column name to use if the selected
866+
* table is auto-created by QWP ingestion.
867+
* <p>
868+
* Supporting servers use this hint only while creating a missing table
869+
* and silently ignore it for existing tables. Older QWP servers also
870+
* silently ignore it and create the conventional {@code timestamp}
871+
* column instead.
872+
* <p>
873+
* The hint is sticky per table. It may be re-declared with a different
874+
* name while the table has no buffered rows; changing it while rows
875+
* are buffered throws.
876+
*
877+
* @param columnName non-empty column name of at most 127 UTF-8 bytes
878+
* @return this instance for method chaining
879+
* @throws LineSenderException if the name is invalid or these options
880+
* no longer refer to the selected table
881+
*/
882+
TableOptions designatedTimestamp(CharSequence columnName);
883+
}
884+
840885
/**
841886
* Configure TLS mode.
842887
* Most users should not need to use anything but the default mode.

core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public abstract class WebSocketClient implements QuietCloseable {
8383
private static final String QWP_DURABLE_ACK_ENABLED_VALUE = "enabled";
8484
private static final String QWP_DURABLE_ACK_HEADER_NAME = "X-QWP-Durable-Ack:";
8585
private static final String QWP_MAX_BATCH_SIZE_HEADER_NAME = "X-QWP-Max-Batch-Size:";
86+
private static final String QWP_TABLE_OPTIONS_HEADER_NAME = "X-QWP-Table-Options:";
8687
private static final String QWP_VERSION_HEADER_NAME = "X-QWP-Version:";
8788
private static final ThreadLocal<MessageDigest> SHA1_DIGEST = ThreadLocal.withInitial(() -> {
8889
try {
@@ -162,6 +163,7 @@ public abstract class WebSocketClient implements QuietCloseable {
162163
// the wire said without re-clamping so a misconfigured server is observable.
163164
private int serverNegotiatedZstdLevel;
164165
private int serverQwpVersion = 1;
166+
private boolean serverTableOptionsSupported;
165167
private String upgradeRejectRole;
166168
// Server-advertised zone identifier from the most recent rejected upgrade,
167169
// captured from the X-QuestDB-Zone response header on a 421. Null when the
@@ -410,6 +412,14 @@ public boolean isServerDurableAckEnabled() {
410412
return serverDurableAckEnabled;
411413
}
412414

415+
/**
416+
* Returns true when the server advertised support for the designated
417+
* timestamp name table option in {@code X-QWP-Table-Options}.
418+
*/
419+
public boolean isServerTableOptionsSupported() {
420+
return serverTableOptionsSupported;
421+
}
422+
413423
/**
414424
* Receives and processes WebSocket frames.
415425
*
@@ -614,6 +624,7 @@ public void upgrade(CharSequence path, int timeout, CharSequence authorizationHe
614624
upgradeRejectRole = null;
615625
upgradeRejectZone = null;
616626
upgradeStatusCode = 0;
627+
serverTableOptionsSupported = false;
617628

618629
// Generate random key
619630
byte[] keyBytes = new byte[16];
@@ -822,6 +833,35 @@ private static int extractQwpVersion(String response) {
822833
return 1;
823834
}
824835

836+
private static boolean extractTableOptionsSupport(String response) {
837+
int headerLen = QWP_TABLE_OPTIONS_HEADER_NAME.length();
838+
int responseLen = response.length();
839+
for (int i = 0; i <= responseLen - headerLen; i++) {
840+
if (response.regionMatches(true, i, QWP_TABLE_OPTIONS_HEADER_NAME, 0, headerLen)) {
841+
int valueStart = i + headerLen;
842+
int lineEnd = response.indexOf('\r', valueStart);
843+
if (lineEnd < 0) {
844+
lineEnd = responseLen;
845+
}
846+
String value = response.substring(valueStart, lineEnd);
847+
int tokenStart = 0;
848+
while (tokenStart <= value.length()) {
849+
int comma = value.indexOf(',', tokenStart);
850+
int tokenEnd = comma < 0 ? value.length() : comma;
851+
if ("1".equals(value.substring(tokenStart, tokenEnd).trim())) {
852+
return true;
853+
}
854+
if (comma < 0) {
855+
break;
856+
}
857+
tokenStart = comma + 1;
858+
}
859+
return false;
860+
}
861+
}
862+
return false;
863+
}
864+
825865
private static String extractRoleHeader(String response) {
826866
int headerLen = QUESTDB_ROLE_HEADER_NAME.length();
827867
int responseLen = response.length();
@@ -1360,6 +1400,10 @@ private void validateUpgradeResponse(int headerEnd) {
13601400
// sender falls back to its locally configured byte budget in that case.
13611401
serverMaxBatchSize = extractMaxBatchSize(response);
13621402

1403+
// Extract the supported per-table option tags. Tag 1 is the
1404+
// designated timestamp column name hint.
1405+
serverTableOptionsSupported = extractTableOptionsSupport(response);
1406+
13631407
// Extract X-QWP-Content-Encoding (optional). Surfaces what level the
13641408
// server actually applied -- which may differ from what this client
13651409
// asked for if the server has qwp.egress.compression.force.level set

core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnWriter.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,20 @@ private void writeTableHeader(String tableName, int rowCount, QwpColumnDef[] col
289289
}
290290
}
291291

292+
private void writeTableOptions(QwpTableBuffer tableBuffer) {
293+
String designatedTimestampName = tableBuffer.getDesignatedTimestampName();
294+
if (designatedTimestampName == null) {
295+
buffer.putVarint(0);
296+
return;
297+
}
298+
299+
int nameLength = NativeBufferWriter.utf8Length(designatedTimestampName);
300+
int blockLength = 1 + NativeBufferWriter.varintSize(nameLength) + nameLength;
301+
buffer.putVarint(blockLength);
302+
buffer.putByte(TABLE_OPTION_TAG_DESIGNATED_TIMESTAMP_NAME);
303+
buffer.putString(designatedTimestampName);
304+
}
305+
292306
private void writeTimestampColumn(long addr, int count, boolean useGorilla) {
293307
if (useGorilla && count > 2) {
294308
// Single pass: check feasibility and compute encoded size together
@@ -348,6 +362,21 @@ void encodeTable(
348362
}
349363
}
350364

365+
int encodeTableOptions(QwpTableBuffer tableBuffer) {
366+
int start = buffer.getPosition();
367+
writeTableOptions(tableBuffer);
368+
return buffer.getPosition() - start;
369+
}
370+
371+
static int getSingleTableOptionsTrailerSize(CharSequence designatedTimestampName) {
372+
if (designatedTimestampName == null) {
373+
return 0;
374+
}
375+
int nameLength = NativeBufferWriter.utf8Length(designatedTimestampName);
376+
int blockLength = 1 + NativeBufferWriter.varintSize(nameLength) + nameLength;
377+
return NativeBufferWriter.varintSize(blockLength) + blockLength + Integer.BYTES;
378+
}
379+
351380
void setBuffer(QwpBufferWriter buffer) {
352381
this.buffer = buffer;
353382
}

core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpUdpSender.java

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ public class QwpUdpSender implements Sender {
8181
private final SegmentedNativeBufferWriter payloadWriter;
8282
private final CharSequenceObjHashMap<QwpTableBuffer> tableBuffers;
8383
private final CharSequenceObjHashMap<TableHeadroomState> tableHeadroomStates;
84+
private final TableOptionsImpl tableOptions = new TableOptionsImpl();
8485
private final boolean trackDatagramEstimate;
8586
private QwpTableBuffer.ColumnBuffer cachedTimestampColumn;
8687
private QwpTableBuffer.ColumnBuffer cachedTimestampNanosColumn;
@@ -591,6 +592,13 @@ public Sender table(CharSequence tableName) {
591592
return this;
592593
}
593594

595+
@Override
596+
public TableOptions tableOptions() {
597+
checkNotClosed();
598+
checkTableSelected();
599+
return tableOptions.of(currentTableBuffer);
600+
}
601+
594602
@Override
595603
public Sender timestampColumn(CharSequence columnName, long value, ChronoUnit unit) {
596604
checkNotClosed();
@@ -866,6 +874,15 @@ private void appendLongArrayValue(QwpTableBuffer.ColumnBuffer column, Object val
866874
throw new LineSenderException("unsupported long array type");
867875
}
868876

877+
private void appendTableOptionsForUdp(QwpTableBuffer tableBuffer) {
878+
if (tableBuffer.getDesignatedTimestampName() == null) {
879+
return;
880+
}
881+
int trailerStart = payloadWriter.getPosition();
882+
columnWriter.encodeTableOptions(tableBuffer);
883+
payloadWriter.putInt(payloadWriter.getPosition() - trailerStart);
884+
}
885+
869886
private void atMicros(long timestampMicros) {
870887
try {
871888
stageDesignatedTimestampValue(timestampMicros, false);
@@ -1007,6 +1024,7 @@ private int encodeCommittedPrefixPayloadForUdp(QwpTableBuffer tableBuffer) {
10071024
false,
10081025
false
10091026
);
1027+
appendTableOptionsForUdp(tableBuffer);
10101028
payloadWriter.finish();
10111029
return payloadWriter.getPosition();
10121030
}
@@ -1015,6 +1033,7 @@ private int encodeTablePayloadForUdp(QwpTableBuffer tableBuffer) {
10151033
payloadWriter.reset();
10161034
columnWriter.setBuffer(payloadWriter);
10171035
columnWriter.encodeTable(tableBuffer, false, false);
1036+
appendTableOptionsForUdp(tableBuffer);
10181037
payloadWriter.finish();
10191038
return payloadWriter.getPosition();
10201039
}
@@ -1106,6 +1125,9 @@ private long estimateBaseForCurrentSchema() {
11061125
estimate += 1;
11071126
}
11081127
}
1128+
estimate += QwpColumnWriter.getSingleTableOptionsTrailerSize(
1129+
currentTableBuffer.getDesignatedTimestampName()
1130+
);
11091131
if (currentTableHeadroomState != null) {
11101132
currentTableHeadroomState.cacheBaseEstimate(currentTableBuffer.getColumnCount(), estimate);
11111133
}
@@ -1218,17 +1240,17 @@ private void rollbackCurrentRowToCommittedState() {
12181240

12191241
private void sendCommittedPrefix(CharSequence tableName, QwpTableBuffer tableBuffer) {
12201242
int payloadLength = encodeCommittedPrefixPayloadForUdp(tableBuffer);
1221-
sendEncodedPayload(tableName, payloadLength);
1243+
sendEncodedPayload(tableName, tableBuffer, payloadLength);
12221244
}
12231245

1224-
private void sendEncodedPayload(CharSequence tableName, int payloadLength) {
1246+
private void sendEncodedPayload(CharSequence tableName, QwpTableBuffer tableBuffer, int payloadLength) {
12251247
headerBuffer.reset();
12261248
headerBuffer.putByte((byte) 'Q');
12271249
headerBuffer.putByte((byte) 'W');
12281250
headerBuffer.putByte((byte) 'P');
12291251
headerBuffer.putByte((byte) '1');
12301252
headerBuffer.putByte(VERSION);
1231-
headerBuffer.putByte((byte) 0);
1253+
headerBuffer.putByte(tableBuffer.getDesignatedTimestampName() == null ? 0 : FLAG_TABLE_OPTIONS);
12321254
headerBuffer.putShort((short) 1);
12331255
headerBuffer.putInt(payloadLength);
12341256

@@ -1251,7 +1273,7 @@ private void sendEncodedPayload(CharSequence tableName, int payloadLength) {
12511273

12521274
private void sendWholeTableBuffer(CharSequence tableName, QwpTableBuffer tableBuffer) {
12531275
int payloadLength = encodeTablePayloadForUdp(tableBuffer);
1254-
sendEncodedPayload(tableName, payloadLength);
1276+
sendEncodedPayload(tableName, tableBuffer, payloadLength);
12551277
tableBuffer.reset();
12561278
}
12571279

@@ -1483,6 +1505,11 @@ long getCachedBaseEstimate(int currentSchemaColumnCount) {
14831505
return cachedBaseEstimateColumnCount == currentSchemaColumnCount ? cachedBaseEstimate : -1;
14841506
}
14851507

1508+
void invalidateBaseEstimate() {
1509+
cachedBaseEstimate = -1;
1510+
cachedBaseEstimateColumnCount = -1;
1511+
}
1512+
14861513
long predictNextRowGrowth() {
14871514
if (committedSampleCount < 2) {
14881515
return 0;
@@ -1509,4 +1536,36 @@ void recordCommittedRow(int currentSchemaColumnCount, long rowDatagramGrowth) {
15091536
}
15101537
}
15111538
}
1539+
1540+
private final class TableOptionsImpl implements TableOptions {
1541+
private QwpTableBuffer tableBuffer;
1542+
1543+
@Override
1544+
public TableOptions designatedTimestamp(CharSequence columnName) {
1545+
checkNotClosed();
1546+
if (tableBuffer != currentTableBuffer) {
1547+
throw new LineSenderException(
1548+
"table options reference is stale; call tableOptions() after table()"
1549+
);
1550+
}
1551+
String previousName = tableBuffer.getDesignatedTimestampName();
1552+
tableBuffer.setDesignatedTimestampName(columnName);
1553+
if (previousName == null) {
1554+
if (currentTableHeadroomState != null) {
1555+
currentTableHeadroomState.invalidateBaseEstimate();
1556+
}
1557+
if (trackDatagramEstimate && committedDatagramEstimate > 0) {
1558+
committedDatagramEstimate += QwpColumnWriter.getSingleTableOptionsTrailerSize(
1559+
tableBuffer.getDesignatedTimestampName()
1560+
);
1561+
}
1562+
}
1563+
return this;
1564+
}
1565+
1566+
private TableOptions of(QwpTableBuffer tableBuffer) {
1567+
this.tableBuffer = tableBuffer;
1568+
return this;
1569+
}
1570+
}
15121571
}

0 commit comments

Comments
 (0)