Skip to content

Commit 514e789

Browse files
committed
feat(qwp): expose drainer listener on the QuestDB facade and Sender builder
BackgroundDrainerListener was settable only on the internal drainer/pool objects; users of the QuestDB pool entry point had no way to observe background orphan-slot drainer events. Mirror the existing errorHandler/ connectionListener plumbing end to end: - QuestDBBuilder.drainerListener(...) -> QuestDBImpl -> SenderPool applyUserCallbacks -> every pooled Sender (recovery delegates keep skipping user callbacks and forcing drain_orphans=off, unchanged) - Sender.LineSenderBuilder.drainerListener(...) with the WebSocket- transport guard; build() installs it via setDrainerListener BEFORE startOrphanDrainers so drainers see the listener at submit time - QwpWebSocketSender.setDrainerListener(...): synchronized with startOrphanDrainers; installs the pool submit-time default and re-assigns live drainers via pool.snapshot() when called late E2E against the real TestWebSocketServer (new suppress-durable-ack-header knob, setRejectWithStatus style): the facade-wired listener observes the capability-gap episode (onDurableAckUnavailable 1..N) and the drain then succeeds once the header is restored; a 421+REPLICA window is delivered on onPrimaryUnavailable with onDurableAckUnavailable staying silent (M10 discrimination on the wire); same gap scenario one level down through Sender.builder().drainerListener(...).
1 parent 1582e3a commit 514e789

7 files changed

Lines changed: 609 additions & 19 deletions

File tree

core/src/main/java/io/questdb/client/QuestDBBuilder.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
package io.questdb.client;
2626

2727
import io.questdb.client.cutlass.qwp.client.QwpQueryClient;
28+
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener;
2829
import io.questdb.client.impl.ConfigString;
2930
import io.questdb.client.impl.ConfigView;
3031
import io.questdb.client.impl.QuestDBImpl;
@@ -69,6 +70,7 @@ public final class QuestDBBuilder {
6970
// Optional ingest-side async callbacks. Null -> each pooled Sender uses its
7071
// loud-not-silent default. Applied to every Sender the pool builds.
7172
private SenderConnectionListener connectionListener;
73+
private BackgroundDrainerListener drainerListener;
7274
private SenderErrorHandler errorHandler;
7375
private long housekeeperIntervalMillis = UNSET;
7476
private String config;
@@ -127,6 +129,25 @@ public QuestDBBuilder connectionListener(SenderConnectionListener listener) {
127129
return this;
128130
}
129131

132+
/**
133+
* Sets the background orphan-slot drainer listener applied to every pooled
134+
* ingest {@link Sender}. The listener observes the background drainer
135+
* events of every sender the pool builds: durable-ack capability-gap
136+
* retries, transient all-replica failover windows, and the eventual
137+
* escalation to a {@code .failed} sentinel. Events are delivered on the
138+
* drainers' own threads, so the listener must be thread-safe and must not
139+
* block. Only meaningful when the configuration enables
140+
* {@code drain_orphans}. Pass {@code null} (the default) to keep the
141+
* drainers' default (no listener).
142+
*
143+
* @param listener the shared drainer listener, or {@code null} for the default
144+
* @return this instance for method chaining
145+
*/
146+
public QuestDBBuilder drainerListener(BackgroundDrainerListener listener) {
147+
this.drainerListener = listener;
148+
return this;
149+
}
150+
130151
/**
131152
* Sets the async error handler applied to every pooled ingest
132153
* {@link Sender}. The handler receives terminal/async ingest errors
@@ -209,7 +230,8 @@ public QuestDB build() {
209230
housekeeperIntervalMillis,
210231
queryCloseTimeoutMillis,
211232
errorHandler,
212-
connectionListener
233+
connectionListener,
234+
drainerListener
213235
);
214236
}
215237

core/src/main/java/io/questdb/client/Sender.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,11 @@ final class LineSenderBuilder {
10221022
// Bounded inbox capacity for the async connection-event dispatcher.
10231023
// PARAMETER_NOT_SET_EXPLICITLY → spec default (64).
10241024
private int connectionListenerInboxCapacity = PARAMETER_NOT_SET_EXPLICITLY;
1025+
// Optional user-supplied observer for background orphan-slot drainer
1026+
// events (durable-ack capability-gap retries, all-replica failover
1027+
// windows, persistent-failure escalation). When null, drainers run
1028+
// without a listener. Only meaningful with drainOrphans=true.
1029+
private io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener drainerListener;
10251030
// Orphan adoption: when true, the foreground sender scans
10261031
// <sf_dir>/*/ at startup for sibling slots that hold unacked data
10271032
// and reports them. Default false. Spec calls for spawning
@@ -1585,6 +1590,12 @@ public Sender build() {
15851590
// WebSocketClient inside the abandoned `connected`.
15861591
connected.setTransactional(transactional);
15871592
try {
1593+
// Install the drainer listener BEFORE startOrphanDrainers
1594+
// below: drainers must see the listener at submit time so
1595+
// no early drainer event is lost to a late installation.
1596+
if (drainerListener != null) {
1597+
connected.setDrainerListener(drainerListener);
1598+
}
15881599
// Once the foreground sender is up, dispatch drainers
15891600
// for any sibling orphan slots. Scan AFTER we acquire
15901601
// our own slot lock so we never accidentally try to
@@ -1787,6 +1798,31 @@ public LineSenderBuilder disableAutoFlush() {
17871798
return this;
17881799
}
17891800

1801+
/**
1802+
* Sets the async listener observing background orphan-slot drainer
1803+
* events: per-attempt durable-ack capability-gap retries
1804+
* ({@code onDurableAckUnavailable}), transient all-replica failover
1805+
* windows ({@code onPrimaryUnavailable}), and the eventual escalation
1806+
* to a {@code .failed} sentinel
1807+
* ({@code onDurableAckPersistentFailure}). The listener runs on the
1808+
* drainers' own threads, so it must be thread-safe and must not block
1809+
* — hand off to a queue or metrics sink and return. Only meaningful
1810+
* when {@link #drainOrphans(boolean)} is enabled.
1811+
*
1812+
* <p>WebSocket transport only; setting on other transports throws.
1813+
*
1814+
* @param listener the listener; {@code null} keeps the default (no listener)
1815+
* @return this instance for method chaining
1816+
*/
1817+
public LineSenderBuilder drainerListener(
1818+
io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener listener) {
1819+
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
1820+
throw new LineSenderException("drainer_listener is only supported for WebSocket transport");
1821+
}
1822+
this.drainerListener = listener;
1823+
return this;
1824+
}
1825+
17901826
/**
17911827
* Opt in to adopting sibling slots under {@code <sf_dir>/*} at
17921828
* startup that hold unacked data left behind by a crashed sender or

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import io.questdb.client.cutlass.line.LineSenderException;
4040
import io.questdb.client.cutlass.line.array.DoubleArray;
4141
import io.questdb.client.cutlass.line.array.LongArray;
42+
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener;
4243
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool;
4344
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
4445
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
@@ -205,6 +206,11 @@ public class QwpWebSocketSender implements Sender {
205206
private CursorSendEngine cursorEngine;
206207
private CursorWebSocketSendLoop cursorSendLoop;
207208
private boolean deferCommit;
209+
// User-supplied observer for background orphan-slot drainer events.
210+
// Volatile: written by setDrainerListener (any thread, before or after
211+
// startOrphanDrainers) and read at pool-creation time. Null -> drainers
212+
// run without a listener.
213+
private volatile BackgroundDrainerListener drainerListener;
208214
// Orphan-slot drainer pool. Non-null only when the builder requested
209215
// drain_orphans=true AND we have a slot path to scan against. Closed
210216
// alongside the cursor send loop in close().
@@ -2079,6 +2085,33 @@ public void setCursorEngine(CursorSendEngine engine, boolean takeOwnership) {
20792085
this.ownsCursorEngine = takeOwnership && engine != null;
20802086
}
20812087

2088+
/**
2089+
* Register an async observer for background orphan-slot drainer events.
2090+
* May be called either before or after {@link #startOrphanDrainers} —
2091+
* when called before, the drainer pool picks it up as its submit-time
2092+
* default; when called after, it propagates to the pool AND to every
2093+
* live drainer (per-drainer re-assignment while running is explicitly
2094+
* permitted by the drainer's listener contract). Pass {@code null} to
2095+
* clear. {@code synchronized} to coordinate with
2096+
* {@code startOrphanDrainers}: a concurrent submit either observes the
2097+
* pool listener already set or is covered by the snapshot propagation.
2098+
*/
2099+
public synchronized void setDrainerListener(BackgroundDrainerListener listener) {
2100+
this.drainerListener = listener;
2101+
BackgroundDrainerPool pool = drainerPool;
2102+
if (pool != null) {
2103+
// Submit-time fallback for drainers not yet submitted...
2104+
pool.setListener(listener);
2105+
// ...and direct re-assignment for the ones already running (the
2106+
// pool listener is only applied at submit time, never after).
2107+
ObjList<io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer> live =
2108+
pool.snapshot();
2109+
for (int i = 0, n = live.size(); i < n; i++) {
2110+
live.getQuick(i).setListener(listener);
2111+
}
2112+
}
2113+
}
2114+
20822115
/**
20832116
* Configure the user-supplied error handler. May be called either before
20842117
* or after {@code connect()} — when called after, the change propagates
@@ -2177,6 +2210,9 @@ public synchronized void startOrphanDrainers(
21772210
if (drainerPool == null) {
21782211
drainerPool = new io.questdb.client.cutlass.qwp.client.sf.cursor
21792212
.BackgroundDrainerPool(maxBackgroundDrainers);
2213+
// Install the user listener as the pool's submit-time default so
2214+
// the drainers submitted below observe it from their first event.
2215+
drainerPool.setListener(this.drainerListener);
21802216
}
21812217
for (int i = 0, n = orphanSlotPaths.size(); i < n; i++) {
21822218
String slot = orphanSlotPaths.get(i);

core/src/main/java/io/questdb/client/impl/QuestDBImpl.java

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import io.questdb.client.SenderConnectionListener;
3131
import io.questdb.client.SenderErrorHandler;
3232
import io.questdb.client.cutlass.qwp.client.QwpQueryClient;
33+
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener;
3334
import org.jetbrains.annotations.TestOnly;
3435

3536
import java.util.function.Consumer;
@@ -64,11 +65,13 @@ public QuestDBImpl(
6465
long housekeeperIntervalMillis,
6566
long queryCloseTimeoutMillis,
6667
SenderErrorHandler errorHandler,
67-
SenderConnectionListener connectionListener
68+
SenderConnectionListener connectionListener,
69+
BackgroundDrainerListener drainerListener
6870
) {
6971
this(ingestConfig, queryConfig, senderMin, senderMax, queryMin, queryMax,
7072
acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis,
71-
housekeeperIntervalMillis, queryCloseTimeoutMillis, null, null, errorHandler, connectionListener);
73+
housekeeperIntervalMillis, queryCloseTimeoutMillis, null, null,
74+
errorHandler, connectionListener, drainerListener);
7275
}
7376

7477
// Test-only constructor exposing the senderFactory and connectHook seams:
@@ -95,13 +98,14 @@ public QuestDBImpl(
9598
this(ingestConfig, queryConfig, senderMin, senderMax, queryMin, queryMax,
9699
acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis,
97100
housekeeperIntervalMillis, QueryClientPool.DEFAULT_CLOSE_QUERY_TIMEOUT_MILLIS,
98-
senderFactory, connectHook, null, null);
101+
senderFactory, connectHook, null, null, null);
99102
}
100103

101-
// Full constructor adding the ingest-side errorHandler/connectionListener,
102-
// applied by SenderPool to every Sender it builds. The 12-arg overload above
103-
// is the unchanged white-box test seam and delegates here with null
104-
// callbacks; the public overload delegates here with null test seams.
104+
// Full constructor adding the ingest-side errorHandler/connectionListener/
105+
// drainerListener, applied by SenderPool to every Sender it builds. The
106+
// 12-arg overload above is the unchanged white-box test seam and delegates
107+
// here with null callbacks; the public overload delegates here with null
108+
// test seams.
105109
QuestDBImpl(
106110
String ingestConfig,
107111
String queryConfig,
@@ -117,7 +121,8 @@ public QuestDBImpl(
117121
IntFunction<Sender> senderFactory,
118122
Consumer<QwpQueryClient> connectHook,
119123
SenderErrorHandler errorHandler,
120-
SenderConnectionListener connectionListener
124+
SenderConnectionListener connectionListener,
125+
BackgroundDrainerListener drainerListener
121126
) {
122127
SenderPool builtSenderPool = null;
123128
QueryClientPool builtQueryPool = null;
@@ -130,7 +135,7 @@ public QuestDBImpl(
130135
// build() never blocks on a slow / reachable-but-not-acking
131136
// server; the housekeeper drives it via runStartupRecoveryStep().
132137
true,
133-
errorHandler, connectionListener);
138+
errorHandler, connectionListener, drainerListener);
134139
builtQueryPool = new QueryClientPool(
135140
queryConfig, queryMin, queryMax, acquireTimeoutMillis,
136141
idleTimeoutMillis, maxLifetimeMillis, connectHook);

core/src/main/java/io/questdb/client/impl/SenderPool.java

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import io.questdb.client.SenderErrorHandler;
3030
import io.questdb.client.cutlass.line.LineSenderException;
3131
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
32+
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener;
3233
import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
3334
import io.questdb.client.std.Files;
3435
import io.questdb.client.std.IntList;
@@ -102,6 +103,7 @@ public final class SenderPool implements AutoCloseable {
102103
// User-supplied ingest callbacks, shared across every pooled Sender this
103104
// pool builds. Null -> each sender keeps its loud-not-silent default.
104105
private final SenderConnectionListener connectionListener;
106+
private final BackgroundDrainerListener drainerListener;
105107
private final SenderErrorHandler errorHandler;
106108
private final long idleTimeoutMillis;
107109
// Test seam. Production builds delegates via defaultSender(); white-box
@@ -195,7 +197,7 @@ public SenderPool(
195197
long maxLifetimeMillis
196198
) {
197199
this(configurationString, minSize, maxSize, acquireTimeoutMillis,
198-
idleTimeoutMillis, maxLifetimeMillis, null, false, null, null);
200+
idleTimeoutMillis, maxLifetimeMillis, null, false, null, null, null);
199201
}
200202

201203
// Test-only constructor exposing the senderFactory seam: production builds
@@ -239,14 +241,14 @@ public SenderPool(
239241
) {
240242
this(configurationString, minSize, maxSize, acquireTimeoutMillis,
241243
idleTimeoutMillis, maxLifetimeMillis, senderFactory,
242-
deferStartupRecovery, null, null);
244+
deferStartupRecovery, null, null, null);
243245
}
244246

245-
// Full constructor adding the user-supplied ingest callbacks (error handler
246-
// and connection listener), applied to every Sender the pool builds (see
247-
// buildManagedSlotSender). The public 6-arg ctor and the test-only
248-
// senderFactory overloads above both delegate here with null callbacks; the
249-
// pooled QuestDB handle calls this directly.
247+
// Full constructor adding the user-supplied ingest callbacks (error
248+
// handler, connection listener and background-drainer listener), applied
249+
// to every Sender the pool builds (see buildManagedSlotSender). The public
250+
// 6-arg ctor and the test-only senderFactory overloads above both delegate
251+
// here with null callbacks; the pooled QuestDB handle calls this directly.
250252
SenderPool(
251253
String configurationString,
252254
int minSize,
@@ -257,13 +259,15 @@ public SenderPool(
257259
IntFunction<Sender> senderFactory,
258260
boolean deferStartupRecovery,
259261
SenderErrorHandler errorHandler,
260-
SenderConnectionListener connectionListener
262+
SenderConnectionListener connectionListener,
263+
BackgroundDrainerListener drainerListener
261264
) {
262265
if (minSize < 0 || maxSize < 1 || minSize > maxSize) {
263266
throw new IllegalArgumentException("invalid pool sizing: min=" + minSize + ", max=" + maxSize);
264267
}
265268
this.errorHandler = errorHandler;
266269
this.connectionListener = connectionListener;
270+
this.drainerListener = drainerListener;
267271
this.senderFactory = senderFactory != null ? senderFactory : this::defaultSender;
268272
// An injected factory (tests) drives recovery too, preserving the
269273
// white-box recovery seam; production recovery forces OFF-mode connects
@@ -1048,6 +1052,9 @@ private Sender.LineSenderBuilder applyUserCallbacks(Sender.LineSenderBuilder bui
10481052
if (connectionListener != null) {
10491053
builder.connectionListener(connectionListener);
10501054
}
1055+
if (drainerListener != null) {
1056+
builder.drainerListener(drainerListener);
1057+
}
10511058
return builder;
10521059
}
10531060

0 commit comments

Comments
 (0)