Skip to content

Commit ad25cdf

Browse files
committed
test(qwp): red-first -- background drainer connects must be invisible in the foreground connection-event stream
Orphan drainers share buildAndConnect with the foreground sender, so every drainer (re)connect commits foreground connection state and dispatches lifecycle events on the shared dispatcher. Two black-box contracts, both red today: - a drainer's successful connect fires a fabricated RECONNECTED/FAILED_OVER (and steals the once-per-lifetime CONNECTED classification) while the foreground connection never dropped; - a drainer's mid-drain wire drop fires a phantom DISCONNECTED against an endpoint the foreground is healthily using. Both tests assert the post-fix contract (SenderConnectionEvent describes the FOREGROUND connection's lifecycle) through public API only: real Sender from config, real TestWebSocketServer, events captured via the connectionListener builder hook. Absence-assertions are guarded by the drain-success barrier, the dispatcher's close-time inbox drain, and getDroppedConnectionNotifications() == 0.
1 parent 0f9857e commit ad25cdf

1 file changed

Lines changed: 376 additions & 0 deletions

File tree

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
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;
26+
27+
import io.questdb.client.Sender;
28+
import io.questdb.client.SenderConnectionEvent;
29+
import io.questdb.client.SenderConnectionListener;
30+
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
31+
import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
32+
import io.questdb.client.std.Files;
33+
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
34+
import io.questdb.client.test.tools.TestUtils;
35+
import org.jetbrains.annotations.NotNull;
36+
import org.junit.After;
37+
import org.junit.Assert;
38+
import org.junit.Before;
39+
import org.junit.Test;
40+
41+
import java.io.IOException;
42+
import java.nio.ByteBuffer;
43+
import java.nio.ByteOrder;
44+
import java.nio.file.Paths;
45+
import java.util.ArrayList;
46+
import java.util.List;
47+
import java.util.concurrent.TimeUnit;
48+
49+
/**
50+
* Contract: background orphan-slot drainers are invisible in the foreground
51+
* sender's connection-event stream. {@link SenderConnectionEvent}s describe
52+
* the FOREGROUND connection's lifecycle — the documented meaning a monitoring
53+
* integration depends on: {@code CONNECTED} fires once when the sender first
54+
* comes up, {@code RECONNECTED}/{@code FAILED_OVER} fire when the sender's own
55+
* connection was re-established, {@code DISCONNECTED} fires when the sender's
56+
* own connection dropped. A drainer connecting, reconnecting after a wire
57+
* drop, or failing over is background bookkeeping for an orphan slot and must
58+
* not masquerade as foreground lifecycle transitions.
59+
* <p>
60+
* Both tests are black-box: real {@code Sender} built from config, real
61+
* {@link TestWebSocketServer}, events captured through the public
62+
* {@code connectionListener} builder hook. They do not care HOW drainer
63+
* connects are isolated from foreground state — any implementation that keeps
64+
* drainer activity out of the user-visible event stream passes.
65+
* <p>
66+
* Barriers: the drain outcome is awaited via the public drainer counters
67+
* before close; sender close drains the event-dispatcher inbox before
68+
* returning, so post-close assertions observe the complete delivered stream;
69+
* {@code getDroppedConnectionNotifications() == 0} guards the
70+
* absence-assertions against inbox-overflow false greens.
71+
*/
72+
public class DrainerForegroundEventIsolationTest {
73+
74+
private static final int GHOST_ROWS = 5;
75+
76+
private String sfDir;
77+
78+
@Before
79+
public void setUp() {
80+
sfDir = Paths.get(System.getProperty("java.io.tmpdir"),
81+
"qdb-drainer-event-iso-" + System.nanoTime()).toString();
82+
}
83+
84+
@After
85+
public void tearDown() {
86+
if (sfDir != null) rmDirRec(sfDir);
87+
}
88+
89+
/**
90+
* A drainer's successful connect must not fire a foreground success event.
91+
* The foreground connects exactly once against a healthy server and never
92+
* drops, so the event stream must contain exactly one success-kind event:
93+
* the initial {@code CONNECTED}. A second success-kind event means the
94+
* drainer's connect leaked into the foreground lifecycle stream (today it
95+
* surfaces as a fabricated {@code RECONNECTED}/{@code FAILED_OVER} while
96+
* the foreground connection never went away).
97+
*/
98+
@Test
99+
public void testDrainerConnectMustNotFireForegroundSuccessEvents() throws Exception {
100+
TestUtils.assertMemoryLeak(() -> {
101+
seedGhostSlot();
102+
103+
RecordingListener events = new RecordingListener();
104+
AckAllHandler handler = new AckAllHandler();
105+
try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
106+
server.start();
107+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
108+
109+
String cfg = "ws::addr=localhost:" + server.getPort()
110+
+ ";sf_dir=" + sfDir
111+
+ ";sender_id=primary"
112+
+ ";drain_orphans=true"
113+
+ ";max_background_drainers=1;";
114+
try (Sender sender = Sender.builder(cfg)
115+
.connectionListener(events)
116+
.build()) {
117+
QwpWebSocketSender ws = (QwpWebSocketSender) sender;
118+
awaitDrainSuccess(ws, handler.distinctPayloads, 10_000);
119+
Assert.assertEquals(
120+
"absence-assertions require a lossless event stream",
121+
0, ws.getDroppedConnectionNotifications());
122+
}
123+
// Sender is closed: the dispatcher inbox has been drained, the
124+
// captured list is the complete delivered stream.
125+
List<SenderConnectionEvent> successes = events.ofKinds(
126+
SenderConnectionEvent.Kind.CONNECTED,
127+
SenderConnectionEvent.Kind.RECONNECTED,
128+
SenderConnectionEvent.Kind.FAILED_OVER);
129+
Assert.assertEquals(
130+
"background drainer connects must be invisible in the "
131+
+ "foreground connection-event stream; expected the "
132+
+ "initial CONNECTED only, got: " + successes,
133+
1, successes.size());
134+
Assert.assertEquals(
135+
"the single success event must be the foreground's "
136+
+ "first-connect CONNECTED",
137+
SenderConnectionEvent.Kind.CONNECTED,
138+
successes.get(0).getKind());
139+
}
140+
});
141+
}
142+
143+
/**
144+
* A drainer's mid-drain wire drop must not fire a foreground
145+
* {@code DISCONNECTED}. The server deterministically drops the drainer's
146+
* first connection after acking one frame; the drainer reconnects and
147+
* finishes the slot. The foreground connection is healthy for the whole
148+
* test (it never sends and is never dropped), so a {@code DISCONNECTED}
149+
* in the stream is a phantom: it reports an outage, against an endpoint
150+
* the foreground is healthily using, that the foreground never had.
151+
*/
152+
@Test
153+
public void testDrainerWireDropMustNotFirePhantomForegroundDisconnect() throws Exception {
154+
TestUtils.assertMemoryLeak(() -> {
155+
seedGhostSlot();
156+
157+
RecordingListener events = new RecordingListener();
158+
DropFirstDataConnectionHandler handler = new DropFirstDataConnectionHandler();
159+
try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
160+
server.start();
161+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
162+
163+
String cfg = "ws::addr=localhost:" + server.getPort()
164+
+ ";sf_dir=" + sfDir
165+
+ ";sender_id=primary"
166+
+ ";drain_orphans=true"
167+
+ ";max_background_drainers=1;";
168+
try (Sender sender = Sender.builder(cfg)
169+
.connectionListener(events)
170+
.build()) {
171+
QwpWebSocketSender ws = (QwpWebSocketSender) sender;
172+
awaitDrainSuccess(ws, handler.distinctPayloads, 15_000);
173+
// Fixture sanity: the drain really did span a wire drop —
174+
// at least two distinct data connections served frames.
175+
Assert.assertTrue(
176+
"expected the drainer to reconnect after the scripted "
177+
+ "drop; data connections=" + handler.dataConnections(),
178+
handler.dataConnections() >= 2);
179+
Assert.assertEquals(
180+
"absence-assertions require a lossless event stream",
181+
0, ws.getDroppedConnectionNotifications());
182+
}
183+
List<SenderConnectionEvent> disconnects = events.ofKinds(
184+
SenderConnectionEvent.Kind.DISCONNECTED);
185+
Assert.assertEquals(
186+
"a background drainer's wire drop must not surface as a "
187+
+ "foreground DISCONNECTED — the foreground connection "
188+
+ "never dropped; got: " + disconnects,
189+
0, disconnects.size());
190+
}
191+
});
192+
}
193+
194+
// Ghost sender against a silent server leaves an unacked orphan slot with
195+
// GHOST_ROWS frames under the group root (same recipe as
196+
// BackgroundDrainerEndToEndTest).
197+
private void seedGhostSlot() throws Exception {
198+
try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
199+
silent.start();
200+
Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
201+
String cfg = "ws::addr=localhost:" + silent.getPort()
202+
+ ";sf_dir=" + sfDir
203+
+ ";sender_id=ghost"
204+
+ ";close_flush_timeout_millis=0;";
205+
try (Sender g = Sender.fromConfig(cfg)) {
206+
for (int i = 0; i < GHOST_ROWS; i++) {
207+
g.table("foo").longColumn("v", i).atNow();
208+
g.flush();
209+
}
210+
}
211+
}
212+
Assert.assertEquals("ghost slot must be a candidate orphan",
213+
1, OrphanScanner.scan(sfDir, "primary").size());
214+
}
215+
216+
private static void awaitDrainSuccess(
217+
QwpWebSocketSender ws,
218+
java.util.Set<String> distinctPayloads,
219+
long timeoutMillis
220+
) throws InterruptedException {
221+
long deadline = System.currentTimeMillis() + timeoutMillis;
222+
while (System.currentTimeMillis() < deadline
223+
&& (distinctPayloads.size() < GHOST_ROWS
224+
|| ws.getTotalBackgroundDrainersSucceeded() < 1)) {
225+
Thread.sleep(20);
226+
}
227+
Assert.assertEquals("drainer must replay every ghost-slot row",
228+
GHOST_ROWS, distinctPayloads.size());
229+
Assert.assertEquals("drainer must drain the slot fully and exit cleanly",
230+
1, ws.getTotalBackgroundDrainersSucceeded());
231+
}
232+
233+
private static void rmDirRec(String dir) {
234+
if (!Files.exists(dir)) return;
235+
long find = Files.findFirst(dir);
236+
if (find > 0) {
237+
try {
238+
int rc = 1;
239+
while (rc > 0) {
240+
String name = Files.utf8ToString(Files.findName(find));
241+
if (name != null && !".".equals(name) && !"..".equals(name)) {
242+
String child = dir + "/" + name;
243+
if (!Files.remove(child)) rmDirRec(child);
244+
}
245+
rc = Files.findNext(find);
246+
}
247+
} finally {
248+
Files.findClose(find);
249+
}
250+
}
251+
Files.remove(dir);
252+
}
253+
254+
// status OK + wire seq + tableCount 0 — the minimal ack the non-durable
255+
// drain path consumes (same shape as BackgroundDrainerEndToEndTest).
256+
private static byte[] buildAck(long wireSeq) {
257+
byte[] buf = new byte[1 + 8 + 2];
258+
ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
259+
bb.put((byte) 0x00);
260+
bb.putLong(wireSeq);
261+
bb.putShort((short) 0);
262+
return buf;
263+
}
264+
265+
/** Captures every delivered event for post-close exact assertions. */
266+
private static final class RecordingListener implements SenderConnectionListener {
267+
private final List<SenderConnectionEvent> captured = new ArrayList<>();
268+
269+
@Override
270+
public synchronized void onEvent(@NotNull SenderConnectionEvent event) {
271+
captured.add(event);
272+
}
273+
274+
synchronized List<SenderConnectionEvent> ofKinds(SenderConnectionEvent.Kind... kinds) {
275+
List<SenderConnectionEvent> out = new ArrayList<>();
276+
for (int i = 0, n = captured.size(); i < n; i++) {
277+
SenderConnectionEvent e = captured.get(i);
278+
for (SenderConnectionEvent.Kind k : kinds) {
279+
if (e.getKind() == k) {
280+
out.add(e);
281+
break;
282+
}
283+
}
284+
}
285+
return out;
286+
}
287+
}
288+
289+
private static class SilentHandler implements TestWebSocketServer.WebSocketServerHandler {
290+
@Override
291+
public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
292+
// intentionally no ack
293+
}
294+
}
295+
296+
/**
297+
* Acks every frame with a per-connection wire sequence. The foreground
298+
* connection never sends data in these tests, so only drainer connections
299+
* show up here.
300+
*/
301+
private static class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler {
302+
final java.util.Set<String> distinctPayloads =
303+
java.util.Collections.synchronizedSet(new java.util.HashSet<>());
304+
private final java.util.Map<TestWebSocketServer.ClientHandler, long[]> wireSeqByConn =
305+
new java.util.IdentityHashMap<>();
306+
307+
@Override
308+
public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
309+
distinctPayloads.add(java.util.Arrays.toString(data));
310+
long[] counter = wireSeqByConn.get(client);
311+
if (counter == null) {
312+
counter = new long[1];
313+
wireSeqByConn.put(client, counter);
314+
}
315+
try {
316+
client.sendBinary(buildAck(counter[0]++));
317+
} catch (IOException ignored) {
318+
// best-effort: connection may be racing its own close
319+
}
320+
}
321+
}
322+
323+
/**
324+
* Deterministic mid-drain wire drop. The first connection that sends a
325+
* binary frame (the drainer — the foreground never sends in these tests)
326+
* gets exactly one frame acked, then the server closes its socket on the
327+
* next frame. Every later connection acks all traffic with a
328+
* per-connection wire sequence, so the reconnected drain runs to
329+
* completion. State is keyed per {@code ClientHandler} identity: a dead
330+
* connection's reader can deliver late buffered frames after a newer
331+
* connection started, and those must neither ack with a stale counter nor
332+
* disturb the live connection (same discipline as
333+
* BackgroundDrainerMidDrainCapabilityGapTest's GapScenarioHandler).
334+
*/
335+
private static class DropFirstDataConnectionHandler
336+
implements TestWebSocketServer.WebSocketServerHandler {
337+
final java.util.Set<String> distinctPayloads =
338+
java.util.Collections.synchronizedSet(new java.util.HashSet<>());
339+
private final List<TestWebSocketServer.ClientHandler> arrivalOrder = new ArrayList<>();
340+
private final java.util.Map<TestWebSocketServer.ClientHandler, long[]> wireSeqByConn =
341+
new java.util.IdentityHashMap<>();
342+
343+
synchronized int dataConnections() {
344+
return arrivalOrder.size();
345+
}
346+
347+
@Override
348+
public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
349+
distinctPayloads.add(java.util.Arrays.toString(data));
350+
long[] counter = wireSeqByConn.get(client);
351+
if (counter == null) {
352+
counter = new long[1];
353+
wireSeqByConn.put(client, counter);
354+
arrivalOrder.add(client);
355+
}
356+
boolean firstConnection = arrivalOrder.get(0) == client;
357+
long seq = counter[0]++;
358+
try {
359+
if (firstConnection) {
360+
if (seq == 0) {
361+
client.sendBinary(buildAck(seq));
362+
} else if (seq == 1) {
363+
client.close(); // mid-drain wire drop
364+
}
365+
// seq > 1: late buffered frames from the condemned
366+
// connection; ignore.
367+
} else {
368+
client.sendBinary(buildAck(seq));
369+
}
370+
} catch (IOException ignored) {
371+
// best-effort: the connection died under us; the drainer
372+
// replays on its next connection
373+
}
374+
}
375+
}
376+
}

0 commit comments

Comments
 (0)