|
| 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