Skip to content

Commit 5b51236

Browse files
committed
fix(qwp): mid-drain durable-ack capability gap re-enters the settle budget instead of quarantining on the first sweep
A capability gap surfacing through a mid-drain reconnect sweep used to hit BackgroundDrainer.run()'s generic wire-error path and quarantine the slot immediately, bypassing the 16-attempt/wall-clock settle budget that the initial-connect path grants the exact same condition (rolling upgrade: primary restarts as a non-durable-ack build while a drain is in flight). CursorWebSocketSendLoop now latches a typed capabilityGapTerminal marker (written before the terminalError volatile, first-writer-wins) that the drainer consults after checkError() throws: on a gap terminal it recycles loop + client and re-enters connectWithDurableAckRetry(), which owns the episode budget and drops the .failed sentinel itself if the gap persists. Every other terminal keeps the original quarantine path, and the foreground sender keeps its spec'd loud-fail (the marker is package-private and never consulted there).
1 parent 4200697 commit 5b51236

3 files changed

Lines changed: 535 additions & 30 deletions

File tree

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

Lines changed: 74 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,10 @@ public BackgroundDrainer() {
159159
}
160160

161161
/**
162-
* Initial connect with retry on whole-cluster durable-ack unavailability.
162+
* Budgeted connect with retry on whole-cluster durable-ack unavailability:
163+
* the initial connect, and re-entered from {@link #run()} whenever a
164+
* mid-drain reconnect sweep hits the same capability gap (each re-entry
165+
* is a fresh episode -- a successful connect ended the previous one).
163166
* The wrapped {@code clientFactory.reconnect()} already walks every
164167
* configured endpoint per attempt and only throws
165168
* {@link QwpDurableAckMismatchException} when none of them advertise
@@ -429,37 +432,78 @@ public void run() {
429432
// already dropped on the FAILED path.
430433
return;
431434
}
432-
loop = new CursorWebSocketSendLoop(
433-
client, engine,
434-
0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
435-
clientFactory,
436-
reconnectMaxDurationMillis,
437-
reconnectInitialBackoffMillis,
438-
reconnectMaxBackoffMillis,
439-
requestDurableAck,
440-
durableAckKeepaliveIntervalMillis);
441-
loop.start();
442-
435+
// One iteration per wire session. Re-entered ONLY when a mid-drain
436+
// reconnect sweep hit a durable-ack CAPABILITY gap: that is the
437+
// exact rolling-upgrade condition the settle budget in
438+
// connectWithDurableAckRetry() exists for, so it must not
439+
// quarantine on the first sweep the way the initial-connect path
440+
// never does. The engine stays alive across sessions (it holds the
441+
// slot lock; only loop + client are recycled), and target remains
442+
// valid -- the slot is orphaned, nothing appends to it.
443+
drain:
443444
while (!stopRequested) {
444-
long acked = engine.ackedFsn();
445-
this.ackedFsn = acked;
446-
if (acked >= target) {
447-
outcome = DrainOutcome.SUCCESS;
448-
LOG.info("drainer fully drained slot {} (target={}, acked={})",
449-
slotPath, target, acked);
450-
return;
451-
}
452-
try {
453-
loop.checkError();
454-
} catch (Throwable t) {
455-
String msg = t.getMessage();
456-
LOG.error("drainer wire error for slot {}: {}", slotPath, msg);
457-
lastErrorMessage = msg;
458-
OrphanScanner.markFailed(slotPath, "wire: " + msg);
459-
outcome = DrainOutcome.FAILED;
460-
return;
445+
loop = new CursorWebSocketSendLoop(
446+
client, engine,
447+
0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
448+
clientFactory,
449+
reconnectMaxDurationMillis,
450+
reconnectInitialBackoffMillis,
451+
reconnectMaxBackoffMillis,
452+
requestDurableAck,
453+
durableAckKeepaliveIntervalMillis);
454+
loop.start();
455+
456+
while (!stopRequested) {
457+
long acked = engine.ackedFsn();
458+
this.ackedFsn = acked;
459+
if (acked >= target) {
460+
outcome = DrainOutcome.SUCCESS;
461+
LOG.info("drainer fully drained slot {} (target={}, acked={})",
462+
slotPath, target, acked);
463+
return;
464+
}
465+
try {
466+
loop.checkError();
467+
} catch (Throwable t) {
468+
if (loop.capabilityGapTerminal() != null) {
469+
// Capability gap mid-drain: recycle the wire, NOT
470+
// the slot. connectWithDurableAckRetry() owns the
471+
// episode budget (16 consecutive gap sweeps /
472+
// wall clock) and drops the sentinel itself if the
473+
// gap persists. The loop's own failed sweep is not
474+
// counted toward the fresh episode -- an off-by-one
475+
// that is immaterial at budget 16.
476+
LOG.warn("drainer slot {}: durable-ack capability gap "
477+
+ "mid-drain ({}), re-entering settle budget",
478+
slotPath, t.getMessage());
479+
try {
480+
loop.close();
481+
} catch (Throwable ignored) {
482+
}
483+
try {
484+
client.close();
485+
} catch (Throwable ignored) {
486+
}
487+
loop = null;
488+
client = connectWithDurableAckRetry();
489+
if (client == null) {
490+
// outcome already set (FAILED after budget
491+
// exhaustion, or STOPPED); sentinel handled.
492+
return;
493+
}
494+
continue drain;
495+
}
496+
String msg = t.getMessage();
497+
LOG.error("drainer wire error for slot {}: {}", slotPath, msg);
498+
lastErrorMessage = msg;
499+
OrphanScanner.markFailed(slotPath, "wire: " + msg);
500+
outcome = DrainOutcome.FAILED;
501+
return;
502+
}
503+
java.util.concurrent.locks.LockSupport.parkNanos(POLL_NANOS);
461504
}
462-
java.util.concurrent.locks.LockSupport.parkNanos(POLL_NANOS);
505+
// Inner loop exits only on stopRequested; fall through to the
506+
// outer condition, which is false for the same reason.
463507
}
464508
outcome = DrainOutcome.STOPPED;
465509
} catch (Throwable t) {

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,17 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
205205
// up" (looks transient).
206206
private volatile boolean hasEverConnected;
207207
private volatile Thread ioThread;
208+
// Typed marker for a durable-ack CAPABILITY-GAP terminal: set (before the
209+
// terminalError latch, so a checkError() caller that observes the latch is
210+
// guaranteed to observe this marker too) when a reconnect sweep threw
211+
// QwpDurableAckMismatchException. The orphan drainer consults it to route
212+
// a mid-drain capability gap into its budgeted settle-retry
213+
// (BackgroundDrainer.connectWithDurableAckRetry) instead of quarantining
214+
// the slot on the first sweep; the foreground sender ignores it and keeps
215+
// its spec'd loud-fail (sf-client.md section 8.1). Write-once alongside
216+
// terminalError: the only writer runs on the I/O thread under the same
217+
// first-writer-wins latch.
218+
private volatile QwpDurableAckMismatchException capabilityGapTerminal;
208219
// The latched terminal failure — THE exception every checkError() call
209220
// rethrows. Write-once for the loop's lifetime: the only writer is
210221
// recordFatal on the I/O thread (first-writer-wins). The whole
@@ -493,6 +504,23 @@ public void checkError() {
493504
}
494505
}
495506

507+
/**
508+
* The typed durable-ack capability-gap terminal, or {@code null} if the
509+
* loop's terminal (if any) is a different failure class. Non-null only
510+
* after {@link #checkError()} started throwing: the marker is written
511+
* before the {@code terminalError} latch, both on the I/O thread.
512+
* <p>
513+
* Consumer contract: the orphan drainer ({@code BackgroundDrainer})
514+
* checks this after a {@code checkError()} throw to decide between
515+
* re-entering its budgeted settle-retry (capability gap: the rolling
516+
* upgrade may still settle) and quarantining the slot (every other
517+
* terminal). Package-private on purpose -- the foreground sender must
518+
* not branch on it (spec'd loud-fail, sf-client.md section 8.1).
519+
*/
520+
QwpDurableAckMismatchException capabilityGapTerminal() {
521+
return capabilityGapTerminal;
522+
}
523+
496524
/**
497525
* Safety-net variant of {@link #checkError()} for
498526
* {@code QwpWebSocketSender.close()}: rethrows the latched terminal error
@@ -895,6 +923,13 @@ private void connectLoop(Throwable initial, String phase) {
895923
// not SECURITY_ERROR -- this is not an auth failure.
896924
LOG.error("durable-ack mismatch during {} -- won't retry: {}",
897925
phase, e.getMessage());
926+
if (terminalError == null) {
927+
// Mirror recordFatal's first-writer-wins latch: only the
928+
// sweep that owns the terminal may mark the gap, and the
929+
// marker must be visible before the terminalError volatile
930+
// write that checkError() keys on.
931+
capabilityGapTerminal = e;
932+
}
898933
long fromFsn = engine.ackedFsn() + 1L;
899934
long toFsn = Math.max(fromFsn, engine.publishedFsn());
900935
SenderError err = new SenderError(

0 commit comments

Comments
 (0)