5757import io .questdb .client .std .Decimal128 ;
5858import io .questdb .client .std .Decimal256 ;
5959import io .questdb .client .std .Decimal64 ;
60+ import io .questdb .client .std .IntList ;
6061import io .questdb .client .std .Misc ;
6162import io .questdb .client .std .Numbers ;
6263import io .questdb .client .std .NumericException ;
@@ -169,6 +170,11 @@ public class QwpWebSocketSender implements Sender {
169170 // behind a drainer's endpoint walk.
170171 private final ReentrantLock connectWalkLock = new ReentrantLock ();
171172 private final QwpHostHealthTracker hostTracker ;
173+ // Per-table encoded body byte counts captured during flushPendingRows' combined
174+ // encode, reused by flushPendingRowsSplit to size each split frame arithmetically
175+ // instead of re-encoding the batch a second time. Cleared and repopulated on every
176+ // flush; only consumed on the (exceptional) split path. Reused to stay zero-GC.
177+ private final IntList splitFrameBodyBytes = new IntList ();
172178 private final CharSequenceObjHashMap <QwpTableBuffer > tableBuffers ;
173179 // null means plain text (no TLS)
174180 private final ClientTlsConfiguration tlsConfig ;
@@ -3474,6 +3480,15 @@ private void flushPendingRows(boolean deferCommit) {
34743480 encoder .setDeferCommit (deferCommit );
34753481 encoder .beginMessage (tableCount , globalSymbolDictionary ,
34763482 symbolDeltaBaseline (), currentBatchMaxSymbolId );
3483+ // Record each table's encoded body size (position delta across addTable) so
3484+ // the split path can size its per-table frames arithmetically rather than
3485+ // re-encoding the whole batch. Body encoding is context-free (a table's bytes
3486+ // don't depend on its siblings or the delta section), so the size a table
3487+ // takes here equals the size it takes in its own split frame. Only consumed
3488+ // when the batch overflows the cap; the capture is a couple of int ops per
3489+ // table on the common path.
3490+ splitFrameBodyBytes .clear ();
3491+ int bodyStart = encoder .getBuffer ().getPosition ();
34773492 for (int i = 0 , n = keys .size (); i < n ; i ++) {
34783493 CharSequence tableName = keys .getQuick (i );
34793494 if (tableName == null ) {
@@ -3490,12 +3505,18 @@ private void flushPendingRows(boolean deferCommit) {
34903505 }
34913506
34923507 encoder .addTable (tableBuffer );
3508+ int bodyEnd = encoder .getBuffer ().getPosition ();
3509+ splitFrameBodyBytes .add (bodyEnd - bodyStart );
3510+ bodyStart = bodyEnd ;
34933511 }
34943512 int messageSize = encoder .finishMessage ();
34953513 QwpBufferWriter buffer = encoder .getBuffer ();
34963514
34973515 if (serverMaxBatchSize > 0 && messageSize > serverMaxBatchSize ) {
3498- flushPendingRowsSplit (keys , deferCommit );
3516+ // The combined frame's delta-entry bytes are byte-identical to the first
3517+ // split frame's (same baseline + batch max), so capture the length now
3518+ // for the arithmetic frame-sizing in flushPendingRowsSplit.
3519+ flushPendingRowsSplit (keys , deferCommit , encoder .getDeltaEntriesLen ());
34993520 return ;
35003521 }
35013522
@@ -3547,7 +3568,7 @@ private void flushPendingRows(boolean deferCommit) {
35473568 * carry FLAG_DEFER_COMMIT. When false, only the
35483569 * last message omits the flag.
35493570 */
3550- private void flushPendingRowsSplit (ObjList <CharSequence > keys , boolean deferCommit ) {
3571+ private void flushPendingRowsSplit (ObjList <CharSequence > keys , boolean deferCommit , int combinedDeltaEntriesLen ) {
35513572 if (LOG .isDebugEnabled ()) {
35523573 LOG .debug ("Splitting flush across multiple messages [serverMaxBatchSize={}, defer={}]" , serverMaxBatchSize , deferCommit );
35533574 }
@@ -3561,15 +3582,19 @@ private void flushPendingRowsSplit(ObjList<CharSequence> keys, boolean deferComm
35613582 // resetTableBuffersAfterFlush would discard every source row -- a partial
35623583 // commit the caller was told (by the throw) had failed. Checking all sizes up
35633584 // front makes the split all-or-nothing: either every frame fits and all
3564- // publish, or none publish and we throw with nothing stranded. The cost is a
3565- // second encode pass over the split batch, which is already the exceptional
3566- // large-batch path. encode is read-only on the table buffer, and simBaseline
3567- // mirrors the publish loop's baseline advance (advanceSentMaxSymbolId), so
3568- // each measured size equals the frame the publish loop will build; this pass
3569- // mutates no delta/persist state (the defer-commit flag is a header bit that
3570- // does not change frame size).
3585+ // publish, or none publish and we throw with nothing stranded.
3586+ //
3587+ // Each split frame's size is derived ARITHMETICALLY from the combined encode
3588+ // flushPendingRows already performed -- header + the two delta-section varints
3589+ // + the delta entries (byte-identical to the combined frame's when this frame
3590+ // carries them) + the table's own body bytes (captured in splitFrameBodyBytes,
3591+ // context-free so identical here and in its solo frame) -- rather than
3592+ // re-encoding every table a second time. simBaseline mirrors the publish loop's
3593+ // baseline advance (advanceSentMaxSymbolId), so each size equals the frame the
3594+ // publish loop will build; this pass mutates no delta/persist state.
35713595 int nonEmptyCount = 0 ;
35723596 int simBaseline = symbolDeltaBaseline ();
3597+ int bodyIdx = 0 ;
35733598 for (int i = 0 , n = keys .size (); i < n ; i ++) {
35743599 CharSequence tableName = keys .getQuick (i );
35753600 if (tableName == null ) {
@@ -3580,9 +3605,14 @@ private void flushPendingRowsSplit(ObjList<CharSequence> keys, boolean deferComm
35803605 continue ;
35813606 }
35823607 nonEmptyCount ++;
3583- encoder .beginMessage (1 , globalSymbolDictionary , simBaseline , currentBatchMaxSymbolId );
3584- encoder .addTable (tableBuffer );
3585- int messageSize = encoder .finishMessage ();
3608+ int deltaStart = simBaseline + 1 ;
3609+ int deltaCount = Math .max (0 , currentBatchMaxSymbolId - simBaseline );
3610+ int messageSize = QwpConstants .HEADER_SIZE
3611+ + NativeBufferWriter .varintSize (deltaStart )
3612+ + NativeBufferWriter .varintSize (deltaCount )
3613+ + (deltaCount > 0 ? combinedDeltaEntriesLen : 0 )
3614+ + splitFrameBodyBytes .getQuick (bodyIdx );
3615+ bodyIdx ++;
35863616 if (messageSize > serverMaxBatchSize ) {
35873617 resetTableBuffersAfterFlush (keys );
35883618 throw new LineSenderException ("single table batch too large for server batch cap" )
@@ -3821,11 +3851,38 @@ private void resetSymbolDictStateForNewConnection() {
38213851 * dictionary shorter than {@code pd.size()} and desyncing
38223852 * {@code sentMaxSymbolId} from the mirror's {@code sentDictCount = pd.size()},
38233853 * which silently misattributes later symbols after a reconnect.
3854+ * <p>
3855+ * <b>Host-crash tear guard.</b> The persisted dictionary is NOT fsync'd (see
3856+ * {@code PersistedSymbolDict}), so a host/power crash can lose its
3857+ * most-recently-written (highest-id) entries while the segment frames that
3858+ * introduced those ids survive -- and those newest frames, being the least
3859+ * likely to be acked, replay on recovery. The send loop's catch-up mirror then
3860+ * rebuilds the missing ids from those frames' own delta bytes, but THIS producer
3861+ * -- seeded only from the shorter dictionary -- would assign its next new symbol
3862+ * an id the surviving frames already define, putting two symbols on one id and
3863+ * silently misattributing values. The send loop's replay guard only catches a
3864+ * GAP ({@code deltaStart > sentDictCount}); a frame that introduces exactly the
3865+ * torn-off id ({@code deltaStart == pd.size()}) slips through and self-heals the
3866+ * mirror, leaving only this producer diverged. Detect it here -- the surviving
3867+ * frames reference an id at or beyond the recovered dictionary size -- and fail
3868+ * clean: the affected data must be resent, matching the design's torn-dict
3869+ * "resend required" contract. The recovered data is not lost; the background
3870+ * drainer still drains this slot (its mirror self-heals from the frames). Only a
3871+ * host crash reaches this -- a process crash keeps the page cache, so the
3872+ * write-ahead ordering keeps the dictionary a superset of the frames.
38243873 */
38253874 private void seedGlobalDictionaryFromPersisted (PersistedSymbolDict pd ) {
38263875 if (pd == null || pd .size () == 0 ) {
38273876 return ;
38283877 }
3878+ if (cursorEngine != null && cursorEngine .recoveredMaxSymbolId () >= pd .size ()) {
3879+ throw new LineSenderException (
3880+ "recovered store-and-forward symbol dictionary is a subset of the surviving frames "
3881+ + "(likely a host crash tore its unsynced tail): frames reference symbol id "
3882+ + cursorEngine .recoveredMaxSymbolId () + " but the recovered dictionary holds only "
3883+ + pd .size () + " id(s); resuming would reuse ids the frames already define -- "
3884+ + "resend the affected data" );
3885+ }
38293886 ObjList <String > symbols = pd .readLoadedSymbols ();
38303887 for (int i = 0 , n = symbols .size (); i < n ; i ++) {
38313888 globalSymbolDictionary .addRecoveredSymbol (symbols .getQuick (i ));
0 commit comments