Skip to content

Commit d912466

Browse files
committed
chore(qwp): drain the old connection's buffered durable ack on failover reconnect
1 parent 6c336d1 commit d912466

2 files changed

Lines changed: 376 additions & 0 deletions

File tree

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
178178
private final WebSocketResponse response = new WebSocketResponse();
179179
private final ResponseHandler responseHandler = new ResponseHandler();
180180
private final CountDownLatch shutdownLatch = new CountDownLatch(1);
181+
private final SwapDrainHandler swapDrainHandler = new SwapDrainHandler();
181182
private final AtomicLong totalAcks = new AtomicLong();
182183
// Counters for observability of the durable-ack path. Both are zero
183184
// when durableAckMode is false.
@@ -1831,6 +1832,25 @@ private void sendDurableAckKeepaliveIfDue() {
18311832
*/
18321833
private void swapClient(WebSocketClient newClient) {
18331834
WebSocketClient old = this.client;
1835+
// A demoting / handing-off primary flushes its final covering
1836+
// STATUS_DURABLE_ACK just before it closes, but by then the failover has
1837+
// already been triggered (by the read-only rejection) and the I/O thread
1838+
// is parked in connectLoop, so that ack sits unread in the old socket's
1839+
// recv buffer. Apply it now -- before we drop the connection and anchor
1840+
// the replay cursor at ackedFsn+1 -- otherwise we replay frames the
1841+
// server already confirmed durable, duplicating them on the promoted
1842+
// node (which already holds them) on a table without dedup keys.
1843+
// swapDrainHandler applies durable acks ONLY and no-ops CLOSE/OK, so a
1844+
// CLOSE trailing the ack in the buffer cannot re-enter fail()/connectLoop.
1845+
if (old != null) {
1846+
try {
1847+
while (old.tryReceiveFrame(swapDrainHandler)) {
1848+
// apply any durable acks buffered on the wire we are leaving
1849+
}
1850+
} catch (Throwable ignored) {
1851+
// best-effort; the connection is going away regardless
1852+
}
1853+
}
18341854
this.client = newClient;
18351855
// Sticky: once the wire is up, we've reached the server at least
18361856
// once for this sender's lifetime. Used downstream to classify a
@@ -2379,4 +2399,31 @@ private void handleServerRejection(long wireSeq) {
23792399
}
23802400
}
23812401
}
2402+
2403+
/**
2404+
* Frame handler used only while {@link #swapClient} is draining the
2405+
* connection it is about to drop. It applies buffered durable acks -- so the
2406+
* replay cursor can be anchored past work the server already confirmed
2407+
* durable -- and ignores everything else: OK / NACK frames (the new
2408+
* connection re-OKs every replayed batch and the server re-emits cumulative
2409+
* durable-ack watermarks from scratch), and a trailing CLOSE, which must not
2410+
* re-enter the failover path already in progress.
2411+
*/
2412+
private final class SwapDrainHandler implements WebSocketFrameHandler {
2413+
@Override
2414+
public void onBinaryMessage(long payloadPtr, int payloadLen) {
2415+
if (!durableAckMode || !response.readFrom(payloadPtr, payloadLen)) {
2416+
return;
2417+
}
2418+
if (response.isDurableAck()) {
2419+
totalDurableAcks.incrementAndGet();
2420+
applyDurableAck();
2421+
}
2422+
}
2423+
2424+
@Override
2425+
public void onClose(int code, String reason) {
2426+
// no-op: this connection is already being swapped out
2427+
}
2428+
}
23822429
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
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.sf.cursor;
26+
27+
import io.questdb.client.DefaultHttpClientConfiguration;
28+
import io.questdb.client.cutlass.http.client.WebSocketClient;
29+
import io.questdb.client.cutlass.http.client.WebSocketFrameHandler;
30+
import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
31+
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
32+
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
33+
import io.questdb.client.network.PlainSocketFactory;
34+
import io.questdb.client.std.Files;
35+
import io.questdb.client.std.MemoryTag;
36+
import io.questdb.client.std.Unsafe;
37+
import io.questdb.client.test.tools.TestUtils;
38+
import org.junit.After;
39+
import org.junit.Before;
40+
import org.junit.Test;
41+
42+
import java.lang.reflect.Field;
43+
import java.lang.reflect.Method;
44+
import java.nio.charset.StandardCharsets;
45+
import java.nio.file.Paths;
46+
47+
import static org.junit.Assert.assertEquals;
48+
49+
/**
50+
* Regression for a QWP store-and-forward exactly-once gap: on a failover
51+
* reconnect, {@link CursorWebSocketSendLoop#swapClient} closes the old
52+
* connection and repositions the replay cursor at {@code ackedFsn + 1} without
53+
* first draining the old connection's receive buffer, so a final
54+
* {@code STATUS_DURABLE_ACK} that arrived on the old wire but was not yet
55+
* processed is discarded. {@code ackedFsn} then stays stale and the loop
56+
* replays frames the server already confirmed durable -- duplicates on the
57+
* failover target (which already holds them) when the table has no dedup keys.
58+
*
59+
* <p>Deterministic, thread-free: the loop is built but never started (frames
60+
* delivered directly into the response handler, à la
61+
* {@link CursorWebSocketSendLoopDurableAckTest}); the <em>old</em>
62+
* {@link WebSocketClient} carries one covering durable ack followed by a CLOSE
63+
* -- the exact shape a demoting primary leaves buffered. The test asserts the
64+
* ack is drained and applied (so the cursor anchors past those frames) and that
65+
* the trailing CLOSE does not re-enter the failover path (the reconnect factory
66+
* throws, so any re-entry would surface as an error).
67+
*
68+
* <p>Before the fix this fails with {@code ackedFsn == -1}: the ack is dropped
69+
* and the loop rewinds to FSN 0.
70+
*/
71+
public class CursorWebSocketSendLoopReconnectDurableAckDrainTest {
72+
73+
private String tmpDir;
74+
75+
@Before
76+
public void setUp() {
77+
tmpDir = Paths.get(System.getProperty("java.io.tmpdir"),
78+
"qdb-cursor-reconnect-da-" + System.nanoTime()).toString();
79+
assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT));
80+
}
81+
82+
@After
83+
public void tearDown() {
84+
if (tmpDir == null) return;
85+
long find = Files.findFirst(tmpDir);
86+
if (find > 0) {
87+
try {
88+
int rc = 1;
89+
while (rc > 0) {
90+
String name = Files.utf8ToString(Files.findName(find));
91+
if (name != null && !".".equals(name) && !"..".equals(name)) {
92+
Files.remove(tmpDir + "/" + name);
93+
}
94+
rc = Files.findNext(find);
95+
}
96+
} finally {
97+
Files.findClose(find);
98+
}
99+
}
100+
Files.remove(tmpDir);
101+
}
102+
103+
@Test
104+
public void testReconnectDrainsBufferedDurableAckBeforeRepositioning() throws Exception {
105+
TestUtils.assertMemoryLeak(() -> {
106+
long ackPacked = buildDurableAckPayload(names("trades"), txns(12L));
107+
long ackPtr = ackPacked & 0xFFFFFFFFFFFFL;
108+
int ackSize = (int) (ackPacked >>> 48);
109+
// Old wire: the covering durable ack (trades<=12), then a CLOSE --
110+
// the shape a demoting primary leaves buffered once it has flushed
111+
// its final ack and shut the write side.
112+
BufferedAckClient oldClient = new BufferedAckClient(ackPtr, ackSize, true);
113+
BufferedAckClient newClient = new BufferedAckClient(0L, 0, false); // inert new wire
114+
try (CursorSendEngine engine = newEngine()) {
115+
appendFrames(engine, 3);
116+
CursorWebSocketSendLoop loop = newDurableLoop(engine, oldClient);
117+
setSentCount(loop, 3);
118+
119+
// Three frames OK'd but, in durable mode, still awaiting the
120+
// durable ack -- nothing trimmed yet.
121+
deliverOk(loop, 0, names("trades"), txns(10L));
122+
deliverOk(loop, 1, names("trades"), txns(11L));
123+
deliverOk(loop, 2, names("trades"), txns(12L));
124+
assertEquals("precondition: no frame durably acked before reconnect",
125+
-1L, engine.ackedFsn());
126+
assertEquals("precondition: ack + CLOSE still buffered on the old wire",
127+
2, oldClient.pending());
128+
129+
// Fail over to the promoted node.
130+
swapClient(loop, newClient);
131+
132+
// The buffered durable ack must be drained and applied before the
133+
// replay cursor is repositioned; otherwise the loop rewinds to
134+
// FSN 0 and replays frames the server already confirmed durable.
135+
assertEquals("buffered durable ack on the old connection must be drained "
136+
+ "and applied on reconnect (else already-durable frames replay -> duplicates)",
137+
2L, engine.ackedFsn());
138+
assertEquals("replay cursor must resume past the durably-acked frames, not rewind over them",
139+
3L, getLongField(loop, "fsnAtZero"));
140+
// Whole buffer consumed, including the trailing CLOSE -- which must
141+
// be a no-op during the swap, not a re-entry into failover (the
142+
// reconnect factory throws, so re-entry would have blown up above).
143+
assertEquals("the old connection's recv buffer must be fully drained",
144+
0, oldClient.pending());
145+
} finally {
146+
oldClient.close(); // idempotent; swapClient already closed it
147+
newClient.close();
148+
Unsafe.free(ackPtr, ackSize, MemoryTag.NATIVE_DEFAULT);
149+
}
150+
});
151+
}
152+
153+
private static void appendFrames(CursorSendEngine engine, int count) {
154+
long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
155+
try {
156+
byte[] payload = "frame-bytes-padd".getBytes(StandardCharsets.US_ASCII);
157+
for (int i = 0; i < payload.length; i++) {
158+
Unsafe.getUnsafe().putByte(buf + i, payload[i]);
159+
}
160+
for (int i = 0; i < count; i++) {
161+
engine.appendBlocking(buf, 16);
162+
}
163+
} finally {
164+
Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
165+
}
166+
}
167+
168+
private static long buildDurableAckPayload(String[] tableNames, long[] seqTxns) {
169+
// STATUS_DURABLE_ACK frame: status(1) + tableCount(2) + entries(nameLen(2)+name+seqTxn(8))
170+
int size = 3;
171+
for (String t : tableNames) size += 2 + t.getBytes(StandardCharsets.UTF_8).length + 8;
172+
long ptr = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
173+
int offset = 0;
174+
Unsafe.getUnsafe().putByte(ptr + offset, WebSocketResponse.STATUS_DURABLE_ACK);
175+
offset += 1;
176+
Unsafe.getUnsafe().putShort(ptr + offset, (short) tableNames.length);
177+
offset += 2;
178+
for (int i = 0; i < tableNames.length; i++) {
179+
byte[] name = tableNames[i].getBytes(StandardCharsets.UTF_8);
180+
Unsafe.getUnsafe().putShort(ptr + offset, (short) name.length);
181+
offset += 2;
182+
for (int j = 0; j < name.length; j++) {
183+
Unsafe.getUnsafe().putByte(ptr + offset + j, name[j]);
184+
}
185+
offset += name.length;
186+
Unsafe.getUnsafe().putLong(ptr + offset, seqTxns[i]);
187+
offset += 8;
188+
}
189+
return ptr | (((long) size) << 48);
190+
}
191+
192+
private static long buildOkPayload(long wireSeq, String[] tableNames, long[] seqTxns) {
193+
// STATUS_OK frame: status(1) + sequence(8) + tableCount(2) + entries
194+
int size = 11;
195+
for (String t : tableNames) size += 2 + t.getBytes(StandardCharsets.UTF_8).length + 8;
196+
long ptr = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
197+
int offset = 0;
198+
Unsafe.getUnsafe().putByte(ptr + offset, WebSocketResponse.STATUS_OK);
199+
offset += 1;
200+
Unsafe.getUnsafe().putLong(ptr + offset, wireSeq);
201+
offset += 8;
202+
Unsafe.getUnsafe().putShort(ptr + offset, (short) tableNames.length);
203+
offset += 2;
204+
for (int i = 0; i < tableNames.length; i++) {
205+
byte[] name = tableNames[i].getBytes(StandardCharsets.UTF_8);
206+
Unsafe.getUnsafe().putShort(ptr + offset, (short) name.length);
207+
offset += 2;
208+
for (int j = 0; j < name.length; j++) {
209+
Unsafe.getUnsafe().putByte(ptr + offset + j, name[j]);
210+
}
211+
offset += name.length;
212+
Unsafe.getUnsafe().putLong(ptr + offset, seqTxns[i]);
213+
offset += 8;
214+
}
215+
return ptr | (((long) size) << 48);
216+
}
217+
218+
private static void deliverOk(CursorWebSocketSendLoop loop, long wireSeq, String[] tableNames, long[] seqTxns) throws Exception {
219+
long packed = buildOkPayload(wireSeq, tableNames, seqTxns);
220+
long ptr = packed & 0xFFFFFFFFFFFFL;
221+
int size = (int) (packed >>> 48);
222+
try {
223+
invokeOnBinaryMessage(loop, ptr, size);
224+
} finally {
225+
Unsafe.free(ptr, size, MemoryTag.NATIVE_DEFAULT);
226+
}
227+
}
228+
229+
private static long getLongField(CursorWebSocketSendLoop loop, String name) throws Exception {
230+
Field f = CursorWebSocketSendLoop.class.getDeclaredField(name);
231+
f.setAccessible(true);
232+
return f.getLong(loop);
233+
}
234+
235+
private static void invokeOnBinaryMessage(CursorWebSocketSendLoop loop, long ptr, int size) throws Exception {
236+
Field f = CursorWebSocketSendLoop.class.getDeclaredField("responseHandler");
237+
f.setAccessible(true);
238+
Object handler = f.get(loop);
239+
Method m = handler.getClass().getDeclaredMethod("onBinaryMessage", long.class, int.class);
240+
m.setAccessible(true);
241+
m.invoke(handler, ptr, size);
242+
}
243+
244+
private static String[] names(String... v) {
245+
return v;
246+
}
247+
248+
private CursorSendEngine newEngine() {
249+
return new CursorSendEngine(tmpDir, 16384);
250+
}
251+
252+
private CursorWebSocketSendLoop newDurableLoop(CursorSendEngine engine, WebSocketClient client) {
253+
return new CursorWebSocketSendLoop(
254+
client, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
255+
() -> {
256+
throw new UnsupportedOperationException("reconnect factory unused: swapClient is driven directly");
257+
},
258+
5_000L, 100L, 5_000L, true);
259+
}
260+
261+
private static void setSentCount(CursorWebSocketSendLoop loop, long count) throws Exception {
262+
Field f = CursorWebSocketSendLoop.class.getDeclaredField("nextWireSeq");
263+
f.setAccessible(true);
264+
f.setLong(loop, count);
265+
}
266+
267+
private static void swapClient(CursorWebSocketSendLoop loop, WebSocketClient newClient) throws Exception {
268+
Method m = CursorWebSocketSendLoop.class.getDeclaredMethod("swapClient", WebSocketClient.class);
269+
m.setAccessible(true);
270+
m.invoke(loop, newClient);
271+
}
272+
273+
private static long[] txns(long... v) {
274+
return v;
275+
}
276+
277+
/**
278+
* In-memory transport that optionally carries a single buffered durable-ack
279+
* frame followed by a CLOSE -- the shape left on the wire by a primary that
280+
* flushed its final ack and then shut down. {@code tryReceiveFrame} delivers
281+
* the ack, then the CLOSE, then nothing. Everything else is inert.
282+
*/
283+
private static final class BufferedAckClient extends WebSocketClient {
284+
private final long framePtr;
285+
private final int frameSize;
286+
private boolean ackPending;
287+
private boolean closePending;
288+
289+
BufferedAckClient(long framePtr, int frameSize, boolean deliverClose) {
290+
super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE);
291+
this.framePtr = framePtr;
292+
this.frameSize = frameSize;
293+
this.ackPending = frameSize > 0;
294+
this.closePending = deliverClose;
295+
}
296+
297+
int pending() {
298+
return (ackPending ? 1 : 0) + (closePending ? 1 : 0);
299+
}
300+
301+
@Override
302+
public void sendBinary(long dataPtr, int length) {
303+
// replayed frames are irrelevant to this test
304+
}
305+
306+
@Override
307+
public boolean tryReceiveFrame(WebSocketFrameHandler handler) {
308+
if (ackPending) {
309+
ackPending = false;
310+
handler.onBinaryMessage(framePtr, frameSize);
311+
return true;
312+
}
313+
if (closePending) {
314+
closePending = false;
315+
handler.onClose(1000, "role-change handoff"); // 1000 = NORMAL_CLOSURE
316+
return true;
317+
}
318+
return false;
319+
}
320+
321+
@Override
322+
protected void ioWait(int timeout, int op) {
323+
}
324+
325+
@Override
326+
protected void setupIoWait() {
327+
}
328+
}
329+
}

0 commit comments

Comments
 (0)