Skip to content

Commit 675d036

Browse files
committed
Avoid re-encoding split QWP frames
1 parent f3282a4 commit 675d036

3 files changed

Lines changed: 205 additions & 95 deletions

File tree

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

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626

2727
import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer;
2828
import io.questdb.client.std.QuietCloseable;
29+
import io.questdb.client.std.Unsafe;
30+
import io.questdb.client.std.Vect;
2931

3032
import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.*;
3133

@@ -45,8 +47,10 @@ public class QwpWebSocketEncoder implements QuietCloseable {
4547
// .symbol-dict instead of re-encoding the same symbols (see
4648
// QwpWebSocketSender.persistNewSymbolsBeforePublish). Valid until the next
4749
// beginMessage; stored as offsets so they survive a buffer realloc.
50+
private int deltaCount;
4851
private int deltaEntriesEnd;
4952
private int deltaEntriesStart;
53+
private int deltaStart;
5054
// QWP ingress always advertises Gorilla timestamp encoding. The column
5155
// writer still emits a per-column encoding byte and falls back to raw
5256
// values when delta-of-delta overflows int32.
@@ -73,8 +77,8 @@ public void beginMessage(
7377
int batchMaxId
7478
) {
7579
buffer.reset();
76-
int deltaStart = confirmedMaxId + 1;
77-
int deltaCount = Math.max(0, batchMaxId - confirmedMaxId);
80+
deltaStart = confirmedMaxId + 1;
81+
deltaCount = Math.max(0, batchMaxId - confirmedMaxId);
7882
byte headerFlags = (byte) (flags | FLAG_DELTA_SYMBOL_DICT);
7983
byte origFlags = flags;
8084
flags = headerFlags;
@@ -100,6 +104,64 @@ public void close() {
100104
}
101105
}
102106

107+
/**
108+
* Copies one single-table split message from the combined message currently
109+
* staged in this encoder. The table body is copied byte-for-byte from its
110+
* recorded offset; columns and rows are not encoded again.
111+
*/
112+
public int copySplitMessage(
113+
MicrobatchBuffer target,
114+
int tableBodyOffset,
115+
int tableBodyLength,
116+
boolean deferCommit,
117+
int confirmedMaxId,
118+
int batchMaxId
119+
) {
120+
if (target.getBufferPos() != 0) {
121+
throw new IllegalStateException("split message target is not empty");
122+
}
123+
if (tableBodyOffset < deltaEntriesEnd
124+
|| tableBodyLength < 0
125+
|| (long) tableBodyOffset + tableBodyLength > buffer.getPosition()) {
126+
throw new IllegalArgumentException("table body slice is outside the staged message");
127+
}
128+
129+
int splitDeltaStart = confirmedMaxId + 1;
130+
int splitDeltaCount = Math.max(0, batchMaxId - confirmedMaxId);
131+
int deltaEntriesLength = splitDeltaEntriesLength(splitDeltaStart, splitDeltaCount);
132+
int messageSize = splitMessageSize(
133+
tableBodyLength, splitDeltaStart, splitDeltaCount, deltaEntriesLength);
134+
target.ensureCapacity(messageSize);
135+
136+
long source = buffer.getBufferPtr();
137+
long destination = target.getBufferPtr();
138+
Vect.memcpy(destination, source, HEADER_SIZE);
139+
140+
byte splitFlags = Unsafe.getUnsafe().getByte(source + HEADER_OFFSET_FLAGS);
141+
if (deferCommit) {
142+
splitFlags |= FLAG_DEFER_COMMIT;
143+
} else {
144+
splitFlags &= ~FLAG_DEFER_COMMIT;
145+
}
146+
Unsafe.getUnsafe().putByte(destination + HEADER_OFFSET_FLAGS, splitFlags);
147+
Unsafe.getUnsafe().putShort(destination + 6, (short) 1);
148+
Unsafe.getUnsafe().putInt(destination + 8, messageSize - HEADER_SIZE);
149+
150+
long writeAddress = destination + HEADER_SIZE;
151+
writeAddress = NativeBufferWriter.writeVarint(writeAddress, splitDeltaStart);
152+
writeAddress = NativeBufferWriter.writeVarint(writeAddress, splitDeltaCount);
153+
if (deltaEntriesLength > 0) {
154+
Vect.memcpy(writeAddress, source + deltaEntriesStart, deltaEntriesLength);
155+
writeAddress += deltaEntriesLength;
156+
}
157+
Vect.memcpy(writeAddress, source + tableBodyOffset, tableBodyLength);
158+
writeAddress += tableBodyLength;
159+
assert writeAddress == destination + messageSize;
160+
161+
target.setBufferPos(messageSize);
162+
return messageSize;
163+
}
164+
103165
public int encode(QwpTableBuffer tableBuffer) {
104166
buffer.reset();
105167
writeHeader(1, 0);
@@ -148,6 +210,17 @@ public int getDeltaEntriesStart() {
148210
return deltaEntriesStart;
149211
}
150212

213+
public int getSplitMessageSize(int tableBodyLength, int confirmedMaxId, int batchMaxId) {
214+
if (tableBodyLength < 0) {
215+
throw new IllegalArgumentException("tableBodyLength must be non-negative");
216+
}
217+
int splitDeltaStart = confirmedMaxId + 1;
218+
int splitDeltaCount = Math.max(0, batchMaxId - confirmedMaxId);
219+
int deltaEntriesLength = splitDeltaEntriesLength(splitDeltaStart, splitDeltaCount);
220+
return splitMessageSize(
221+
tableBodyLength, splitDeltaStart, splitDeltaCount, deltaEntriesLength);
222+
}
223+
151224
public void setDeferCommit(boolean defer) {
152225
if (defer) {
153226
flags |= FLAG_DEFER_COMMIT;
@@ -170,4 +243,35 @@ public void writeHeader(int tableCount, int payloadLength) {
170243
buffer.putShort((short) tableCount);
171244
buffer.putInt(payloadLength);
172245
}
246+
247+
private int splitDeltaEntriesLength(int splitDeltaStart, int splitDeltaCount) {
248+
if (splitDeltaCount == 0) {
249+
return 0;
250+
}
251+
if (splitDeltaStart != deltaStart || splitDeltaCount != deltaCount) {
252+
throw new IllegalStateException("split delta does not match the staged message"
253+
+ " [stagedStart=" + deltaStart
254+
+ ", stagedCount=" + deltaCount
255+
+ ", splitStart=" + splitDeltaStart
256+
+ ", splitCount=" + splitDeltaCount + ']');
257+
}
258+
return deltaEntriesEnd - deltaEntriesStart;
259+
}
260+
261+
private int splitMessageSize(
262+
int tableBodyLength,
263+
int splitDeltaStart,
264+
int splitDeltaCount,
265+
int deltaEntriesLength
266+
) {
267+
long messageSize = (long) HEADER_SIZE
268+
+ NativeBufferWriter.varintSize(splitDeltaStart)
269+
+ NativeBufferWriter.varintSize(splitDeltaCount)
270+
+ deltaEntriesLength
271+
+ tableBodyLength;
272+
if (messageSize > Integer.MAX_VALUE) {
273+
throw new OutOfMemoryError("split QWP message size overflow: " + messageSize);
274+
}
275+
return (int) messageSize;
276+
}
173277
}

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

Lines changed: 47 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,9 @@ public class QwpWebSocketSender implements Sender {
172172
private final ReentrantLock connectWalkLock = new ReentrantLock();
173173
private final QwpHostHealthTracker hostTracker;
174174
// Per-table encoded body byte counts captured during flushPendingRows' combined
175-
// encode, reused by flushPendingRowsSplit to size each split frame arithmetically
176-
// instead of re-encoding the batch a second time. Cleared and repopulated on every
177-
// flush; only consumed on the (exceptional) split path. Reused to stay zero-GC.
175+
// encode. flushPendingRowsSplit uses them both for preflight sizing and to walk
176+
// the staged body slices without encoding the batch a second time. Cleared and
177+
// repopulated on every flush; reused to stay zero-GC.
178178
private final IntList splitFrameBodyBytes = new IntList();
179179
private final CharSequenceObjHashMap<QwpTableBuffer> tableBuffers;
180180
// null means plain text (no TLS)
@@ -3497,15 +3497,13 @@ private void flushPendingRows(boolean deferCommit) {
34973497
encoder.setDeferCommit(deferCommit);
34983498
encoder.beginMessage(tableCount, globalSymbolDictionary,
34993499
symbolDeltaBaseline(), currentBatchMaxSymbolId);
3500-
// Record each table's encoded body size (position delta across addTable) so
3501-
// the split path can size its per-table frames arithmetically rather than
3502-
// re-encoding the whole batch. Body encoding is context-free (a table's bytes
3503-
// don't depend on its siblings or the delta section), so the size a table
3504-
// takes here equals the size it takes in its own split frame. Only consumed
3505-
// when the batch overflows the cap; the capture is a couple of int ops per
3506-
// table on the common path.
3500+
// Record each table's encoded body size (position delta across addTable).
3501+
// When the batch needs splitting, these lengths delimit immutable body
3502+
// slices in the combined encoder buffer for direct frame assembly. The
3503+
// capture is a couple of int ops per table on the common path.
35073504
splitFrameBodyBytes.clear();
3508-
int bodyStart = encoder.getBuffer().getPosition();
3505+
int combinedBodyStart = encoder.getBuffer().getPosition();
3506+
int bodyStart = combinedBodyStart;
35093507
for (int i = 0, n = keys.size(); i < n; i++) {
35103508
CharSequence tableName = keys.getQuick(i);
35113509
if (tableName == null) {
@@ -3538,10 +3536,9 @@ private void flushPendingRows(boolean deferCommit) {
35383536
// the next flush picks up the new cap.
35393537
int cap = serverMaxBatchSize;
35403538
if (cap > 0 && messageSize > cap) {
3541-
// The combined frame's delta-entry bytes are byte-identical to the first
3542-
// split frame's (same baseline + batch max), so capture the length now
3543-
// for the arithmetic frame-sizing in flushPendingRowsSplit.
3544-
flushPendingRowsSplit(keys, deferCommit, encoder.getDeltaEntriesLen(), cap);
3539+
// Keep the completed combined frame staged in the encoder while the
3540+
// split path copies its delta entries and table-body slices.
3541+
flushPendingRowsSplit(keys, deferCommit, combinedBodyStart, cap);
35453542
return;
35463543
}
35473544

@@ -3593,7 +3590,12 @@ private void flushPendingRows(boolean deferCommit) {
35933590
* carry FLAG_DEFER_COMMIT. When false, only the
35943591
* last message omits the flag.
35953592
*/
3596-
private void flushPendingRowsSplit(ObjList<CharSequence> keys, boolean deferCommit, int combinedDeltaEntriesLen, int cap) {
3593+
private void flushPendingRowsSplit(
3594+
ObjList<CharSequence> keys,
3595+
boolean deferCommit,
3596+
int combinedBodyStart,
3597+
int cap
3598+
) {
35973599
if (LOG.isDebugEnabled()) {
35983600
LOG.debug("Splitting flush across multiple messages [serverMaxBatchSize={}, defer={}]", cap, deferCommit);
35993601
}
@@ -3609,14 +3611,10 @@ private void flushPendingRowsSplit(ObjList<CharSequence> keys, boolean deferComm
36093611
// front makes the split all-or-nothing: either every frame fits and all
36103612
// publish, or none publish and we throw with nothing stranded.
36113613
//
3612-
// Each split frame's size is derived ARITHMETICALLY from the combined encode
3613-
// flushPendingRows already performed -- header + the two delta-section varints
3614-
// + the delta entries (byte-identical to the combined frame's when this frame
3615-
// carries them) + the table's own body bytes (captured in splitFrameBodyBytes,
3616-
// context-free so identical here and in its solo frame) -- rather than
3617-
// re-encoding every table a second time. simBaseline mirrors the publish loop's
3618-
// baseline advance (advanceSentMaxSymbolId), so each size equals the frame the
3619-
// publish loop will build; this pass mutates no delta/persist state.
3614+
// Each split frame's size is derived from the combined encode flushPendingRows
3615+
// already performed. simBaseline mirrors the publish loop's baseline advance
3616+
// (advanceSentMaxSymbolId), so each size equals the frame the staged-slice
3617+
// assembler will build; this pass mutates no delta/persist state.
36203618
int nonEmptyCount = 0;
36213619
int simBaseline = symbolDeltaBaseline();
36223620
int bodyIdx = 0;
@@ -3630,13 +3628,8 @@ private void flushPendingRowsSplit(ObjList<CharSequence> keys, boolean deferComm
36303628
continue;
36313629
}
36323630
nonEmptyCount++;
3633-
int deltaStart = simBaseline + 1;
3634-
int deltaCount = Math.max(0, currentBatchMaxSymbolId - simBaseline);
3635-
int messageSize = QwpConstants.HEADER_SIZE
3636-
+ NativeBufferWriter.varintSize(deltaStart)
3637-
+ NativeBufferWriter.varintSize(deltaCount)
3638-
+ (deltaCount > 0 ? combinedDeltaEntriesLen : 0)
3639-
+ splitFrameBodyBytes.getQuick(bodyIdx);
3631+
int messageSize = encoder.getSplitMessageSize(
3632+
splitFrameBodyBytes.getQuick(bodyIdx), simBaseline, currentBatchMaxSymbolId);
36403633
bodyIdx++;
36413634
if (messageSize > cap) {
36423635
resetTableBuffersAfterFlush(keys);
@@ -3653,6 +3646,8 @@ private void flushPendingRowsSplit(ObjList<CharSequence> keys, boolean deferComm
36533646
}
36543647

36553648
int sent = 0;
3649+
bodyIdx = 0;
3650+
int tableBodyOffset = combinedBodyStart;
36563651
for (int i = 0, n = keys.size(); i < n; i++) {
36573652
CharSequence tableName = keys.getQuick(i);
36583653
if (tableName == null) {
@@ -3667,35 +3662,38 @@ private void flushPendingRowsSplit(ObjList<CharSequence> keys, boolean deferComm
36673662
boolean isLast = (sent == nonEmptyCount);
36683663
boolean deferThis = deferCommit || !isLast;
36693664

3670-
encoder.setDeferCommit(deferThis);
3671-
// Each split frame emits the delta above sentMaxSymbolId; the first
3672-
// frame ships the whole batch's new ids and advances the baseline, so
3673-
// the remaining frames carry an empty delta and just reference ids the
3674-
// first frame already registered.
3675-
encoder.beginMessage(1, globalSymbolDictionary,
3676-
symbolDeltaBaseline(), currentBatchMaxSymbolId);
3677-
encoder.addTable(tableBuffer);
3678-
int messageSize = encoder.finishMessage();
3679-
QwpBufferWriter buffer = encoder.getBuffer();
3665+
int tableBodyLength = splitFrameBodyBytes.getQuick(bodyIdx++);
3666+
// Persist before touching activeBuffer. If the write-ahead fails, the
3667+
// caller can retry with both the source rows and active microbatch
3668+
// unchanged. The first frame carries the batch's new symbols; later
3669+
// frames are no-ops once the baseline has advanced.
3670+
persistNewSymbolsBeforePublish();
3671+
ensureActiveBufferReady();
3672+
// The combined encoder buffer remains immutable for the whole split.
3673+
// Assemble this frame directly into the active microbatch: patched
3674+
// header + staged delta bytes + the staged table-body slice. No row or
3675+
// column is encoded a second time.
3676+
int messageSize = encoder.copySplitMessage(
3677+
activeBuffer,
3678+
tableBodyOffset,
3679+
tableBodyLength,
3680+
deferThis,
3681+
symbolDeltaBaseline(),
3682+
currentBatchMaxSymbolId
3683+
);
3684+
tableBodyOffset += tableBodyLength;
36803685
// The pre-flight pass above already verified every split frame fits the
36813686
// cap, so none can be found oversized here -- which is what keeps this
36823687
// loop from publishing (and stranding) a deferred prefix before an
36833688
// oversized table. Both passes size against the SAME snapshot cap, so a
36843689
// mid-flush failover cannot make them disagree; the assert therefore only
36853690
// catches a genuine divergence between the pre-flight arithmetic and the
3686-
// real encode (a future bug), not a cap race. It deliberately does NOT
3691+
// staged assembler (a future bug), not a cap race. It deliberately does NOT
36873692
// reset+throw here, because by this point a prefix may already be on the ring.
36883693
assert messageSize <= cap
36893694
: "split frame exceeded serverMaxBatchSize after pre-flight [table=" + tableName
36903695
+ ", messageSize=" + messageSize + ", serverMaxBatchSize=" + cap + ']';
36913696

3692-
// Write-ahead persist before publish (see flushPendingRows). The
3693-
// first split frame carries the batch's new symbols; the rest are
3694-
// no-ops once the baseline has advanced past them.
3695-
persistNewSymbolsBeforePublish();
3696-
ensureActiveBufferReady();
3697-
activeBuffer.ensureCapacity(messageSize);
3698-
activeBuffer.write(buffer.getBufferPtr(), messageSize);
36993697
activeBuffer.incrementRowCount();
37003698
sealAndSwapBuffer();
37013699
// Frame queued: advance so the next split frame's delta starts above

0 commit comments

Comments
 (0)