Skip to content

Commit 36a6117

Browse files
committed
test(qwp): pin the orphan drainer's inherited periodic sync interval end to end
The build -> startOrphanDrainers -> BackgroundDrainer -> CursorSendEngine inheritance was verified-correct but mutation-invisible: passing 0 anywhere along the chain would drain a PERIODIC slot without checkpoints and still pass the suite (the only startOrphanDrainers test call sites used the 4-arg overload, which itself hardcodes 0L). The new integration test adopts a fabricated orphan under sf_durability=periodic against a never-acking server -- the drain can never finish, so the drainer and its engine stay deterministically observable in the pool snapshot -- and asserts the configured interval at the foreground engine, the drainer, and the drainer's engine, failing on value rather than timeout. Seams: @testonly drainer-pool accessor on the sender plus engine capture and inherited-interval getter on the drainer.
1 parent c0e7fc8 commit 36a6117

3 files changed

Lines changed: 139 additions & 0 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,6 +1729,15 @@ public CursorSendEngine getCursorEngineForTesting() {
17291729
return cursorEngine;
17301730
}
17311731

1732+
/**
1733+
* Background orphan-drainer pool, or {@code null} when
1734+
* {@code drain_orphans} is off or no orphan slot was adopted.
1735+
*/
1736+
@TestOnly
1737+
public BackgroundDrainerPool getDrainerPoolForTesting() {
1738+
return drainerPool;
1739+
}
1740+
17321741
@TestOnly
17331742
public SenderErrorDispatcher getErrorDispatcherForTesting() {
17341743
return errorDispatcher;

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ public final class BackgroundDrainer implements Runnable {
112112
private final long syncIntervalNanos;
113113
/** Latest known {@code engine.ackedFsn()}; published for visibility. */
114114
private volatile long ackedFsn = -1L;
115+
/**
116+
* Engine constructed by {@link #run()}, captured for test observation
117+
* only (e.g. asserting the inherited periodic sync interval). May
118+
* reference an already-closed engine once the drain ends.
119+
*/
120+
private volatile CursorSendEngine engineForTesting;
115121
private volatile String lastErrorMessage;
116122
/**
117123
* Optional observer for durable-ack-unavailable transients and the
@@ -517,6 +523,25 @@ public boolean isStopRequested() {
517523
return stopRequested;
518524
}
519525

526+
/**
527+
* Engine this drainer constructed, or {@code null} until {@link #run()}
528+
* gets past engine construction. The reference outlives the drain, so
529+
* tests can read construction-time state (it may be closed by then).
530+
*/
531+
@TestOnly
532+
public CursorSendEngine getEngineForTesting() {
533+
return engineForTesting;
534+
}
535+
536+
/**
537+
* Periodic SF checkpoint interval this drainer inherited from the
538+
* adopting sender at construction time.
539+
*/
540+
@TestOnly
541+
public long getSyncIntervalNanosForTesting() {
542+
return syncIntervalNanos;
543+
}
544+
520545
/**
521546
* Stop check for the runner thread's park loops that also folds a
522547
* pending thread interrupt into the stop protocol. The pool delivers
@@ -615,6 +640,7 @@ public void run() {
615640
outcome = DrainOutcome.FAILED;
616641
return;
617642
}
643+
engineForTesting = engine;
618644
long target = engine.publishedFsn();
619645
if (engine.ackedFsn() >= target) {
620646
LOG.info("orphan slot already drained: {} (acked={} target={})",

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/OrphanScanIntegrationTest.java

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
package io.questdb.client.test.cutlass.qwp.client.sf;
2626

2727
import io.questdb.client.Sender;
28+
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
29+
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
30+
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool;
2831
import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
2932
import io.questdb.client.std.Files;
3033
import io.questdb.client.std.ObjList;
@@ -148,6 +151,107 @@ public void testScanFindsOrphanFromPriorSenderUnderSameGroupRoot() throws Except
148151
});
149152
}
150153

154+
/**
155+
* The orphan drainer must inherit the adopting sender's PERIODIC
156+
* store-and-forward checkpoint interval end to end:
157+
* {@code Sender.build → startOrphanDrainers → BackgroundDrainer →
158+
* CursorSendEngine}. A {@code 0} anywhere along that chain would make
159+
* the drainer silently replay a PERIODIC slot without periodic
160+
* checkpoints — no error, no log — which is exactly the mutation this
161+
* test exists to catch.
162+
*/
163+
@Test
164+
public void testOrphanDrainerInheritsForegroundPeriodicSyncInterval() throws Exception {
165+
TestUtils.assertMemoryLeak(() -> {
166+
// Phase 1: fabricate an orphan slot holding unacked backlog
167+
// (same recipe as
168+
// testScanFindsOrphanFromPriorSenderUnderSameGroupRoot).
169+
SilentHandler ghostSilent = new SilentHandler();
170+
try (TestWebSocketServer ghostServer = new TestWebSocketServer(ghostSilent)) {
171+
ghostServer.start();
172+
Assert.assertTrue(ghostServer.awaitStart(5, TimeUnit.SECONDS));
173+
174+
String ghostCfg = "ws::addr=localhost:" + ghostServer.getPort()
175+
+ ";sf_dir=" + sfDir + ";sender_id=ghost;close_flush_timeout_millis=0;";
176+
try (Sender ghost = Sender.fromConfig(ghostCfg)) {
177+
ghost.table("foo").longColumn("v", 7L).atNow();
178+
ghost.flush();
179+
Assert.assertTrue("ghost frame must reach the wire before close",
180+
ghostSilent.awaitFrame(5, TimeUnit.SECONDS));
181+
}
182+
}
183+
Assert.assertEquals("ghost slot must be a candidate orphan",
184+
1, OrphanScanner.scan(sfDir, "primary").size());
185+
186+
// Phase 2: adopt with a PERIODIC-durability sender. The server
187+
// never acks, so the adopted slot can never finish draining —
188+
// the drainer (and its engine) stay pinned in the pool snapshot
189+
// while we assert the inheritance chain. The asserts below fail
190+
// on the VALUE, not on a timeout, when inheritance breaks.
191+
try (TestWebSocketServer primaryServer = new TestWebSocketServer(new SilentHandler())) {
192+
primaryServer.start();
193+
Assert.assertTrue(primaryServer.awaitStart(5, TimeUnit.SECONDS));
194+
195+
String primaryCfg = "ws::addr=localhost:" + primaryServer.getPort()
196+
+ ";sf_dir=" + sfDir
197+
+ ";sender_id=primary"
198+
+ ";sf_durability=periodic"
199+
+ ";sf_sync_interval_millis=123"
200+
+ ";drain_orphans=true;";
201+
try (Sender primary = Sender.fromConfig(primaryCfg)) {
202+
QwpWebSocketSender ws = (QwpWebSocketSender) primary;
203+
// Anchor: the foreground engine carries the configured
204+
// interval (already pinned by SfFromConfigTest; repeated
205+
// here so both ends of the inheritance are asserted on
206+
// the same sender instance).
207+
Assert.assertEquals(123_000_000L,
208+
ws.getCursorEngineForTesting().getSyncIntervalNanosForTesting());
209+
210+
BackgroundDrainerPool pool = ws.getDrainerPoolForTesting();
211+
Assert.assertNotNull("a candidate orphan under drain_orphans=true "
212+
+ "must have built the drainer pool", pool);
213+
214+
// The drainer constructs its engine on its own thread —
215+
// await it. Once seen it cannot leave the snapshot: with
216+
// no acks the drain never completes.
217+
BackgroundDrainer drainer = null;
218+
long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
219+
while (System.nanoTime() < deadlineNanos) {
220+
ObjList<BackgroundDrainer> snap = pool.snapshot();
221+
if (snap.size() > 0 && snap.get(0).getEngineForTesting() != null) {
222+
drainer = snap.get(0);
223+
break;
224+
}
225+
Thread.sleep(10);
226+
}
227+
Assert.assertNotNull(
228+
"orphan drainer must construct its engine within the deadline",
229+
drainer);
230+
Assert.assertEquals("exactly one orphan slot was fabricated",
231+
1, pool.snapshot().size());
232+
233+
// The carried finding: sender → drainer leg.
234+
Assert.assertEquals(
235+
"drainer must inherit the adopting sender's "
236+
+ "sf_sync_interval_millis",
237+
123_000_000L, drainer.getSyncIntervalNanosForTesting());
238+
// Drainer → engine leg: the interval actually reached the
239+
// engine that performs the periodic checkpoints.
240+
Assert.assertEquals(
241+
"drainer's engine must inherit the adopting sender's "
242+
+ "sf_sync_interval_millis",
243+
123_000_000L,
244+
drainer.getEngineForTesting().getSyncIntervalNanosForTesting());
245+
246+
// Fast teardown: the drainer can never finish against the
247+
// silent server — stop it now so close() spends the
248+
// graceful-drain window on unwinding, not waiting.
249+
drainer.requestStop();
250+
}
251+
}
252+
});
253+
}
254+
151255
@Test
152256
public void testFailedSentinelHidesOrphanFromScan() throws Exception {
153257
TestUtils.assertMemoryLeak(() -> {

0 commit comments

Comments
 (0)