Skip to content

Commit 520231c

Browse files
bluestreak01claude
andcommitted
feat(ilp): cursor frames are self-sufficient — full schemas, full dict
Every frame written through the cursor SF path now carries its full schema definition and the complete symbol-dictionary delta starting at id 0. No schema-by-id refs, no incremental delta-dicts. The bytes persist on disk and get replayed against fresh server connections — post-reconnect, post-restart, or via background drainers adopting orphan slots. A frame whose schema ref points at an ID the new connection has never seen is unrecoverable; the spec's dedup-by-messageSequence assumption fixes duplicates, not stale refs. The previous testReplayResendsUnackedFramesAcrossReconnect only covered single-batch replay (first batch always carries full schemas) so the gap wasn't caught. Implementation: encode pass forces confirmedMaxId=-1 (full symbol delta from 0) and useSchemaRef=false (full schema definition, never a ref). Producer-side maxSentSchemaId / maxSentSymbolId tracking is now effectively dead state — left in place as a no-op safety net. Cost: bytes per batch grow vs the prior delta encoding. Acceptable for correctness across the entire recovery story. Test: SelfSufficientFramesTest sends two batches with distinct symbol values over the same connection and verifies batch 2 carries deltaStart=0 with deltaCount ≥ 2 — i.e. it redefines the prior batch's symbols too, instead of starting from where the prior delta left off. Spec updated with decision #14. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b9b6e2f commit 520231c

3 files changed

Lines changed: 185 additions & 4 deletions

File tree

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1569,7 +1569,15 @@ private void flushPendingRows() {
15691569
lastSeenGeneration = genBefore;
15701570
}
15711571
int currBatchMaxSchemaId = maxSentSchemaId;
1572-
encoder.beginMessage(tableCount, globalSymbolDictionary, maxSentSymbolId, currentBatchMaxSymbolId);
1572+
// Cursor SF requires every on-disk frame to be self-sufficient
1573+
// — its schema definition must travel with the row data, not
1574+
// as a back-reference to an ID the server may not have seen
1575+
// (orphan-slot drainers and post-reconnect replay both deliver
1576+
// recorded frames to fresh server connections). So always emit
1577+
// the full symbol-dict delta from id=0, and always send the
1578+
// full schema definition for each table — never a ref.
1579+
encoder.beginMessage(tableCount, globalSymbolDictionary,
1580+
/*confirmedMaxId=*/ -1, currentBatchMaxSymbolId);
15731581
for (int i = 0, n = keys.size(); i < n; i++) {
15741582
CharSequence tableName = keys.getQuick(i);
15751583
if (tableName == null) {
@@ -1588,13 +1596,13 @@ private void flushPendingRows() {
15881596
tableBuffer.setSchemaId(nextSchemaId++);
15891597
}
15901598
currBatchMaxSchemaId = Math.max(currBatchMaxSchemaId, tableBuffer.getSchemaId());
1591-
boolean useSchemaRef = tableBuffer.getSchemaId() <= maxSentSchemaId;
15921599

15931600
if (LOG.isDebugEnabled()) {
1594-
LOG.debug("Encoding table [name={}, rows={}, maxSentSymbolId={}, batchMaxId={}, useSchemaRef={}]", tableName, tableBuffer.getRowCount(), maxSentSymbolId, currentBatchMaxSymbolId, useSchemaRef);
1601+
LOG.debug("Encoding table [name={}, rows={}, batchMaxId={}, useSchemaRef=false (cursor SF)]",
1602+
tableName, tableBuffer.getRowCount(), currentBatchMaxSymbolId);
15951603
}
15961604

1597-
encoder.addTable(tableBuffer, useSchemaRef);
1605+
encoder.addTable(tableBuffer, /*useSchemaRef=*/ false);
15981606
}
15991607
messageSize = encoder.finishMessage();
16001608
buffer = encoder.getBuffer();
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*+*****************************************************************************
2+
* ___ _ ____ ____
3+
* / _ \ _ _ ___ ___| |_| _ \| __ )
4+
* | | | | | | |/ _ \/ __| __| | | | _ \
5+
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
6+
* \__\_\\__,_|\___||___/\__|____/|____/
7+
*
8+
* Copyright (c) 2014-2019 Appsicle
9+
* Copyright (c) 2019-2026 QuestDB
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*
23+
******************************************************************************/
24+
25+
package io.questdb.client.test.cutlass.qwp.client;
26+
27+
import io.questdb.client.Sender;
28+
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
29+
import org.junit.Assert;
30+
import org.junit.Test;
31+
32+
import java.io.IOException;
33+
import java.nio.ByteBuffer;
34+
import java.nio.ByteOrder;
35+
import java.util.concurrent.TimeUnit;
36+
import java.util.concurrent.atomic.AtomicLong;
37+
38+
/**
39+
* Pins down the "every frame on disk is self-sufficient" rule.
40+
* <p>
41+
* The cursor SF path used to elide schema definitions and previously-sent
42+
* symbols on subsequent batches over the same connection — emitting refs
43+
* + delta-dicts. That's wrong for SF: the bytes survive process restarts
44+
* and are replayed against fresh server connections (post-reconnect, or
45+
* via a background drainer adopting an orphan slot). A frame with a
46+
* schema-ref to an ID the new server has never seen is unrecoverable.
47+
* <p>
48+
* Today every frame must carry its full schema and a complete symbol-dict
49+
* delta starting at id 0. This test asserts both invariants on the wire.
50+
*/
51+
public class SelfSufficientFramesTest {
52+
53+
private static final int TEST_PORT = 19_300 + (int) (System.nanoTime() % 100);
54+
55+
/** First byte of the symbol-dict delta payload after the 12-byte QWP header. */
56+
private static final int DELTA_START_OFFSET = 12;
57+
58+
@Test
59+
public void testEverySymbolBatchIncludesFullDeltaFromZero() throws Exception {
60+
// Send two batches against the same connection, each with a
61+
// distinct symbol value. With the old schema-ref/delta encoding,
62+
// batch 2 would emit deltaStart=1, deltaCount=1 — only the new
63+
// symbol. With self-sufficient frames, batch 2 must emit
64+
// deltaStart=0 covering BOTH symbols.
65+
int port = TEST_PORT + 1;
66+
CapturingHandler handler = new CapturingHandler();
67+
try (TestWebSocketServer server = new TestWebSocketServer(port, handler)) {
68+
server.start();
69+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
70+
71+
try (Sender sender = Sender.fromConfig("ws::addr=localhost:" + port + ";")) {
72+
sender.table("foo").symbol("s", "alpha").longColumn("v", 1L).atNow();
73+
sender.flush();
74+
waitFor(() -> handler.batches.size() >= 1, 5_000);
75+
76+
sender.table("foo").symbol("s", "beta").longColumn("v", 2L).atNow();
77+
sender.flush();
78+
waitFor(() -> handler.batches.size() >= 2, 5_000);
79+
}
80+
81+
Assert.assertEquals("expected 2 captured batches", 2, handler.batches.size());
82+
byte[] b1 = handler.batches.get(0);
83+
byte[] b2 = handler.batches.get(1);
84+
85+
// The deltaStart varint sits right after the 12-byte header.
86+
// For self-sufficient frames it must be 0 (single byte 0x00)
87+
// in BOTH batches — regardless of how many symbols the prior
88+
// batch already shipped.
89+
int deltaStart1 = readVarint(b1, DELTA_START_OFFSET);
90+
int deltaStart2 = readVarint(b2, DELTA_START_OFFSET);
91+
Assert.assertEquals("batch 1 deltaStart must be 0", 0, deltaStart1);
92+
Assert.assertEquals("batch 2 deltaStart must be 0 (self-sufficient)",
93+
0, deltaStart2);
94+
95+
// batch 2 must include >= 2 symbols in its delta dict (alpha
96+
// from the prior batch + beta from this one). The varint at
97+
// DELTA_START_OFFSET+1 is deltaCount.
98+
int deltaCount2 = readVarint(b2, DELTA_START_OFFSET + 1);
99+
Assert.assertTrue("batch 2 must redefine at least 2 symbols, got " + deltaCount2,
100+
deltaCount2 >= 2);
101+
102+
// Sanity: batch 2 should NOT be much smaller than batch 1 —
103+
// with schema-ref/delta encoding it would have been; with
104+
// self-sufficient frames the size is in the same ballpark.
105+
Assert.assertTrue("batch 2 (" + b2.length + " bytes) must not be drastically smaller than batch 1 ("
106+
+ b1.length + ")",
107+
b2.length >= b1.length / 2);
108+
}
109+
}
110+
111+
private static int readVarint(byte[] buf, int offset) {
112+
// Simple unsigned varint decode — sufficient for small values.
113+
int result = 0;
114+
int shift = 0;
115+
while (offset < buf.length) {
116+
int b = buf[offset++] & 0xFF;
117+
result |= (b & 0x7F) << shift;
118+
if ((b & 0x80) == 0) return result;
119+
shift += 7;
120+
if (shift > 28) throw new IllegalStateException("varint too long");
121+
}
122+
throw new IllegalStateException("varint truncated");
123+
}
124+
125+
private static void waitFor(BoolCondition cond, long timeoutMillis) {
126+
long deadline = System.currentTimeMillis() + timeoutMillis;
127+
while (System.currentTimeMillis() < deadline) {
128+
if (cond.test()) return;
129+
try {
130+
Thread.sleep(20);
131+
} catch (InterruptedException e) {
132+
Thread.currentThread().interrupt();
133+
Assert.fail("interrupted");
134+
}
135+
}
136+
Assert.fail("waitFor timed out");
137+
}
138+
139+
@FunctionalInterface
140+
private interface BoolCondition {
141+
boolean test();
142+
}
143+
144+
/** Captures every binary frame for later inspection AND ACKs it. */
145+
private static class CapturingHandler implements TestWebSocketServer.WebSocketServerHandler {
146+
final java.util.List<byte[]> batches =
147+
new java.util.concurrent.CopyOnWriteArrayList<>();
148+
private final AtomicLong nextSeq = new AtomicLong(0);
149+
150+
@Override
151+
public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
152+
batches.add(data.clone());
153+
try {
154+
client.sendBinary(buildAck(nextSeq.getAndIncrement()));
155+
} catch (IOException e) {
156+
throw new RuntimeException(e);
157+
}
158+
}
159+
160+
static byte[] buildAck(long seq) {
161+
byte[] buf = new byte[1 + 8 + 2];
162+
ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
163+
bb.put((byte) 0x00);
164+
bb.putLong(seq);
165+
bb.putShort((short) 0);
166+
return buf;
167+
}
168+
}
169+
}

design/qwp-cursor-durability.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ Each new knob also gets a `LineSenderBuilder` setter.
143143
- Server's dedup window must be ≥ a sender's `sf_max_total_bytes` worth of FSNs (else replay = double-write under sustained outage + full cap).
144144
- Coordination/testing of the recovery + dedup contract is **outside this repo's scope**.
145145

146+
## Self-sufficient frames (locked 2026-04-27)
147+
Every frame written through the cursor SF path **must carry its full schema definition and the complete symbol-dictionary delta from id 0**. No schema-by-id refs, no incremental delta-dicts. The bytes survive process restart and replay against fresh server connections (post-reconnect, post-restart, drainer adopting an orphan slot) — frames with refs to IDs the new server has never seen are unrecoverable. Costs more bytes per batch; pays for replay correctness across every recovery path. Producer-side `maxSentSchemaId` / `maxSentSymbolId` retention is treated as a no-op for the cursor path; the encode call always passes `confirmedMaxId=-1` and `useSchemaRef=false`.
148+
146149
## Decisions locked
147150
1. ✅ flush() never waits for ACK (ACKs are async).
148151
2. ✅ Reconnect cap is per-outage time-based, default 300s.
@@ -157,6 +160,7 @@ Each new knob also gets a `LineSenderBuilder` setter.
157160
11. ✅ Logging: WARN on outage entry/exit-attempt, INFO on reconnect success, ERROR on budget exhaustion; throttled.
158161
12. ✅ Counters and orphan-drainer visibility on `QwpWebSocketSender` (WS-only).
159162
13. ✅ No automatic cleanup of empty slot dirs — preserve goal of data-loss reduction.
163+
14. ✅ Frames on disk are self-sufficient — every frame carries its full schema + full symbol-dict delta from id 0; refs forbidden.
160164

161165
## Open
162166
None. Ready to implement.

0 commit comments

Comments
 (0)