Skip to content

Commit a086575

Browse files
committed
fix(qwp): harden cleanup and recovery lifecycle
1 parent 6b373cb commit a086575

23 files changed

Lines changed: 1987 additions & 100 deletions

core/src/main/c/share/net.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_send
128128
return com_questdb_network_Net_EOTHERDISCONNECT;
129129
}
130130

131+
JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_shutdown
132+
(JNIEnv *e, jclass cl, jint fd) {
133+
return shutdown((int) fd, SHUT_RDWR);
134+
}
135+
131136
JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_recv
132137
(JNIEnv *e, jclass cl, jint fd, jlong ptr, jint len) {
133138
ssize_t n;

core/src/main/c/share/net.h

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/src/main/c/windows/net.c

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,15 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_configureNonBlocking
230230
return res;
231231
}
232232

233+
JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_shutdown
234+
(JNIEnv *e, jclass cl, jint fd) {
235+
const int result = shutdown((SOCKET) fd, SD_BOTH);
236+
if (result == SOCKET_ERROR) {
237+
SaveLastError();
238+
}
239+
return result;
240+
}
241+
233242
JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_recv
234243
(JNIEnv *e, jclass cl, jint fd, jlong addr, jint len) {
235244
const int n = recv((SOCKET) fd, (char *) addr, len, 0);

core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,15 @@ public void close() {
248248
}
249249
}
250250

251+
/**
252+
* Shuts down socket traffic without releasing the descriptor or freeing
253+
* buffers that a concurrent I/O worker may still access. The owner must
254+
* call {@link #close()} after joining the worker to complete cleanup.
255+
*/
256+
public void closeTraffic() {
257+
socket.closeTraffic();
258+
}
259+
251260
/**
252261
* Connects to a WebSocket server.
253262
*

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ public class QwpWebSocketSender implements Sender {
189189
// Null in production; set reflectively by tests.
190190
@TestOnly
191191
private volatile java.util.function.Supplier<WebSocketClient> clientFactoryOverride;
192+
// Test-only lifecycle witness. drainOnClose() invokes and clears it after
193+
// confirming a real unacknowledged close target, immediately before waiting.
194+
private volatile Runnable closeDrainWaitingHook;
192195
// close() drain timeout in millis. Default applied at construction.
193196
// 0 or -1 means "fast close" (skip the drain); otherwise close blocks
194197
// up to this many millis for ackedFsn to catch up to publishedFsn.
@@ -290,6 +293,9 @@ public class QwpWebSocketSender implements Sender {
290293
// re-probe retired slots to recover their capacity. volatile: written on
291294
// the closing thread, read by pool threads.
292295
private volatile boolean slotLockReleased;
296+
// Optional owning-pool notification, relayed after the engine confirms the
297+
// flock release and slotLockReleased is visible to pool re-probes.
298+
private volatile Runnable slotLockReleaseListener;
293299
// Engine whose close() could not complete during sender close() — its
294300
// cleanup is pending on a worker/I/O-thread exit path. isSlotLockReleased()
295301
// re-probes it so a late flock release becomes visible to the owning pool.
@@ -1368,6 +1374,17 @@ public boolean isSlotLockReleased() {
13681374
return false;
13691375
}
13701376

1377+
/**
1378+
* Registers a callback for confirmed SF slot-lock release. Pools use this
1379+
* to wake borrowers without waiting for a timeout or housekeeper tick.
1380+
*/
1381+
public void setSlotLockReleaseListener(Runnable listener) {
1382+
slotLockReleaseListener = listener;
1383+
if (listener != null && slotLockReleased) {
1384+
listener.run();
1385+
}
1386+
}
1387+
13711388
@Override
13721389
public Sender decimalColumn(CharSequence name, Decimal64 value) {
13731390
checkNotClosed();
@@ -2207,6 +2224,16 @@ public void setClientFactoryOverride(java.util.function.Supplier<WebSocketClient
22072224
this.clientFactoryOverride = factory;
22082225
}
22092226

2227+
/**
2228+
* Installs a one-shot test witness that close-time drain invokes after it
2229+
* observes a real unacknowledged target and before it starts waiting.
2230+
* Production code never sets it.
2231+
*/
2232+
@TestOnly
2233+
public void setCloseDrainWaitingHook(Runnable hook) {
2234+
this.closeDrainWaitingHook = hook;
2235+
}
2236+
22102237
/**
22112238
* Installs a one-shot test witness that {@link #close()} invokes after it
22122239
* publishes the closed-state transition and before it starts drain or
@@ -2302,6 +2329,9 @@ public void setCursorEngine(CursorSendEngine engine, boolean takeOwnership) {
23022329
}
23032330
this.cursorEngine = engine;
23042331
this.ownsCursorEngine = takeOwnership && engine != null;
2332+
if (engine != null) {
2333+
engine.setSlotLockReleaseListener(this::onSlotLockReleased);
2334+
}
23052335
}
23062336

23072337
/**
@@ -3272,6 +3302,16 @@ private void drainOnClose(boolean errorOwnedByCustomHandler) {
32723302
if (cursorEngine.ackedFsn() >= target) {
32733303
return;
32743304
}
3305+
Runnable hook = closeDrainWaitingHook;
3306+
closeDrainWaitingHook = null;
3307+
if (hook != null) {
3308+
try {
3309+
hook.run();
3310+
} catch (Throwable t) {
3311+
// A test witness must never prevent production resource cleanup.
3312+
LOG.error("Error in close-drain-waiting test hook: {}", String.valueOf(t));
3313+
}
3314+
}
32753315
long deadlineNanos = System.nanoTime() + closeFlushTimeoutMillis * 1_000_000L;
32763316
while (cursorEngine.ackedFsn() < target) {
32773317
// Stop on a latched terminal (acks will never reach target);
@@ -3632,6 +3672,14 @@ private void flushPendingRowsSplit(ObjList<CharSequence> keys, boolean deferComm
36323672
resetTableBuffersAfterFlush(keys);
36333673
}
36343674

3675+
private void onSlotLockReleased() {
3676+
slotLockReleased = true;
3677+
Runnable listener = slotLockReleaseListener;
3678+
if (listener != null) {
3679+
listener.run();
3680+
}
3681+
}
3682+
36353683
private void resetTableBuffersAfterFlush(ObjList<CharSequence> keys) {
36363684
for (int i = 0, n = keys.size(); i < n; i++) {
36373685
CharSequence tableName = keys.getQuick(i);

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

Lines changed: 125 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
import io.questdb.client.std.QuietCloseable;
3131
import org.jetbrains.annotations.TestOnly;
3232

33+
import java.util.ArrayDeque;
34+
import java.util.concurrent.ThreadFactory;
3335
import java.util.concurrent.atomic.AtomicBoolean;
3436
import java.util.concurrent.locks.LockSupport;
3537

@@ -65,7 +67,15 @@ public final class CursorSendEngine implements QuietCloseable {
6567
public static final long DEFAULT_APPEND_DEADLINE_NANOS = 30_000_000_000L;
6668
private static final org.slf4j.Logger LOG =
6769
org.slf4j.LoggerFactory.getLogger(CursorSendEngine.class);
70+
private static final ThreadFactory DEFAULT_FLOCK_RELEASE_RETRY_THREAD_FACTORY =
71+
runnable -> new Thread(runnable, "qdb-sf-flock-release-retry");
72+
private static final Object FLOCK_RELEASE_RETRY_LOCK = new Object();
73+
private static final ArrayDeque<CursorSendEngine> FLOCK_RELEASE_RETRY_QUEUE = new ArrayDeque<>();
74+
private static volatile Runnable afterFlockReleaseRetryFailureHook;
6875
private static volatile Runnable beforeDeferredCloseCreationHook;
76+
private static Thread flockReleaseRetryThread;
77+
private static volatile ThreadFactory flockReleaseRetryThreadFactory =
78+
DEFAULT_FLOCK_RELEASE_RETRY_THREAD_FACTORY;
6979
private final long appendDeadlineNanos;
7080
// Number of times appendBlocking observed BACKPRESSURE_NO_SPARE on its first
7181
// ring.appendOrFsn attempt. One increment per blocking-call that had to wait
@@ -136,15 +146,18 @@ public final class CursorSendEngine implements QuietCloseable {
136146
// isCloseCompleted() from pool threads re-probing a retired slot (see the
137147
// getter for why it must not synchronize).
138148
private volatile boolean closeCompleted;
149+
// Invoked after closeCompleted publishes a confirmed flock release. Used
150+
// by an owning sender pool to wake capacity-starved borrowers immediately.
151+
private volatile Runnable slotLockReleaseListener;
139152
// Test-only hook run by finishClose() between the terminal cleanup and
140153
// the flock release. Lets a test park the releasing thread inside the
141154
// cleanup/release window and assert that closeCompleted stays false —
142155
// i.e. that completion is never observable while the flock is still
143156
// held. volatile: finishClose may run on the manager worker's exit
144157
// thread while the hook is installed from a test thread.
145158
private volatile Runnable beforeFlockReleaseHook;
146-
// Exactly one daemon drives a failed flock release to completion. The
147-
// error path only: ordinary closes never allocate or start a thread.
159+
// Ensures this engine has at most one entry in the shared flock-release
160+
// retry driver. The error path only: ordinary closes never enqueue work.
148161
private final AtomicBoolean flockReleaseRetryStarted = new AtomicBoolean();
149162
// Published before deferredClose is registered. The manager lock provides
150163
// the callback handoff fence; volatile also covers a direct test/retry read.
@@ -781,6 +794,15 @@ private void finishClose(boolean fullyDrained) {
781794
}
782795
}
783796

797+
/**
798+
* Installs a hook invoked after each failed shared-driver flock release.
799+
* Test-only: makes persistent retry progress observable without sleeps.
800+
*/
801+
@TestOnly
802+
public static void setAfterFlockReleaseRetryFailureHook(Runnable hook) {
803+
afterFlockReleaseRetryFailureHook = hook;
804+
}
805+
784806
/**
785807
* Installs a constructor fault hook immediately before the bound deferred
786808
* close callback is created. Test-only: proves callback allocation failure
@@ -791,6 +813,23 @@ public static void setBeforeDeferredCloseCreationHook(Runnable hook) {
791813
beforeDeferredCloseCreationHook = hook;
792814
}
793815

816+
/**
817+
* Overrides creation of the single shared flock-release retry thread.
818+
* Test-only: makes thread creation/start failure and persistent retry
819+
* scalability deterministic without relying on scheduler timing.
820+
*/
821+
@TestOnly
822+
public static void setFlockReleaseRetryThreadFactory(ThreadFactory factory) {
823+
synchronized (FLOCK_RELEASE_RETRY_LOCK) {
824+
if (flockReleaseRetryThread != null || !FLOCK_RELEASE_RETRY_QUEUE.isEmpty()) {
825+
throw new IllegalStateException("flock-release retry driver is active");
826+
}
827+
flockReleaseRetryThreadFactory = factory == null
828+
? DEFAULT_FLOCK_RELEASE_RETRY_THREAD_FACTORY
829+
: factory;
830+
}
831+
}
832+
794833
/**
795834
* Installs a hook that {@link #finishClose} runs between the terminal
796835
* cleanup and the slot-flock release. Test-only: makes the otherwise
@@ -849,42 +888,97 @@ private boolean retryFlockReleaseIfReady() {
849888
}
850889
if (released) {
851890
closeCompleted = true;
891+
Runnable listener = slotLockReleaseListener;
892+
if (listener != null) {
893+
try {
894+
listener.run();
895+
} catch (Throwable ignored) {
896+
// A notification failure must not invalidate a confirmed release.
897+
}
898+
}
852899
return true;
853900
}
854901
startFlockReleaseRetry();
855902
return false;
856903
}
857904

905+
private static void runFlockReleaseRetryDriver() {
906+
while (true) {
907+
final int roundSize;
908+
synchronized (FLOCK_RELEASE_RETRY_LOCK) {
909+
roundSize = FLOCK_RELEASE_RETRY_QUEUE.size();
910+
if (roundSize == 0) {
911+
flockReleaseRetryThread = null;
912+
return;
913+
}
914+
}
915+
boolean hasFailures = false;
916+
for (int i = 0; i < roundSize; i++) {
917+
final CursorSendEngine engine;
918+
synchronized (FLOCK_RELEASE_RETRY_LOCK) {
919+
engine = FLOCK_RELEASE_RETRY_QUEUE.pollFirst();
920+
}
921+
if (engine.retryFlockReleaseIfReady()) {
922+
engine.flockReleaseRetryStarted.set(false);
923+
} else {
924+
synchronized (FLOCK_RELEASE_RETRY_LOCK) {
925+
FLOCK_RELEASE_RETRY_QUEUE.addLast(engine);
926+
}
927+
hasFailures = true;
928+
Runnable hook = afterFlockReleaseRetryFailureHook;
929+
if (hook != null) {
930+
hook.run();
931+
}
932+
}
933+
}
934+
if (hasFailures) {
935+
// Interruption must not abandon a retained flock, but clear
936+
// the flag so subsequent parks still throttle retries.
937+
Thread.interrupted();
938+
LockSupport.parkNanos(100_000_000L);
939+
}
940+
}
941+
}
942+
858943
private void startFlockReleaseRetry() {
859944
if (!flockReleaseRetryStarted.compareAndSet(false, true)) {
860945
return;
861946
}
862-
try {
863-
Thread retryThread = new Thread(() -> {
864-
while (!retryFlockReleaseIfReady()) {
865-
try {
866-
Thread.sleep(100L);
867-
} catch (InterruptedException ignored) {
868-
// A failed flock release must remain retryable until
869-
// confirmed or process exit; interruption is not a
870-
// safe reason to abandon the slot permanently.
947+
Throwable startFailure = null;
948+
synchronized (FLOCK_RELEASE_RETRY_LOCK) {
949+
FLOCK_RELEASE_RETRY_QUEUE.addLast(this);
950+
if (flockReleaseRetryThread == null) {
951+
try {
952+
Thread retryThread = flockReleaseRetryThreadFactory.newThread(
953+
CursorSendEngine::runFlockReleaseRetryDriver);
954+
if (retryThread == null) {
955+
throw new IllegalStateException("retry thread factory returned null");
956+
}
957+
retryThread.setDaemon(true);
958+
flockReleaseRetryThread = retryThread;
959+
retryThread.start();
960+
} catch (Throwable t) {
961+
startFailure = t;
962+
flockReleaseRetryThread = null;
963+
CursorSendEngine queued;
964+
while ((queued = FLOCK_RELEASE_RETRY_QUEUE.pollFirst()) != null) {
965+
queued.flockReleaseRetryStarted.set(false);
871966
}
872967
}
873-
}, "qdb-sf-flock-release-retry");
874-
retryThread.setDaemon(true);
875-
retryThread.start();
968+
}
969+
}
970+
if (startFailure == null) {
876971
LOG.error("SF slot flock release failed during engine close; keeping "
877-
+ "closeCompleted=false and retrying outside the pool lock so "
972+
+ "closeCompleted=false and retrying on the shared driver so "
878973
+ "retired capacity recovers after the transient failure [slot={}]",
879974
sfDir == null ? "<memory>" : sfDir);
880-
} catch (Throwable t) {
975+
} else {
881976
// A later explicit close() can still retry without repeating the
882-
// one-time ring/watermark cleanup. The kernel remains the final
883-
// process-exit backstop if thread creation and all retries fail.
884-
flockReleaseRetryStarted.set(false);
977+
// one-time ring/watermark cleanup. The failed queue is cleared so
978+
// the driver does not retain engines it cannot service.
885979
LOG.error("Could not start SF flock-release retry driver; close() may be "
886980
+ "invoked again to retry [slot={}, error={}]",
887-
sfDir == null ? "<memory>" : sfDir, String.valueOf(t));
981+
sfDir == null ? "<memory>" : sfDir, String.valueOf(startFailure));
888982
}
889983
}
890984

@@ -910,6 +1004,17 @@ public boolean isCloseCompleted() {
9101004
return closeCompleted;
9111005
}
9121006

1007+
/**
1008+
* Registers a callback for confirmed SF slot-lock release. If release
1009+
* already completed, invokes the callback before returning.
1010+
*/
1011+
public void setSlotLockReleaseListener(Runnable listener) {
1012+
slotLockReleaseListener = listener;
1013+
if (listener != null && closeCompleted) {
1014+
listener.run();
1015+
}
1016+
}
1017+
9131018
/**
9141019
* Pass-through to {@link SegmentRing#findSegmentContaining(long)}.
9151020
*/

0 commit comments

Comments
 (0)