Skip to content

Commit d920b9c

Browse files
committed
fix(qwp): treat all-replica window as transient for durable-ack senders
A durable-ack (request_durable_ack=on) sender that walked an endpoint list where every reachable node was a REPLICA synthesized a terminal QwpDurableAckMismatchException (-> PROTOCOL_VIOLATION/HALT) in QwpWebSocketSender.buildAndConnect. That turned a transient failover window (a replica can be promoted; a primary will reappear) into a permanent hard-fail, violating the store-and-forward contract whose only terminal condition is SF exhaustion. HA senders never recovered even after a replica was promoted: server rejected batch: PROTOCOL_VIOLATION - durable-ack-mismatch: WebSocket upgrade failed: server does not support durable ack [role=REPLICA] Fix: when a connect round exhausts with only role-rejects, throw the retriable QwpRoleMismatchException regardless of request_durable_ack. Both connect paths (connectWithRetry sync, connectLoop async/reconnect) already retry role-mismatch within reconnect_max_duration_millis, so the sender keeps rows in SF and recovers on promotion. A genuine capability gap (an endpoint that upgrades but does not advertise durable ack) stays terminal via terminalUpgradeError. BackgroundDrainer: widen its connect-retry catch to the role-reject types so an orphaned-slot drainer keeps giving the cluster a budget to settle instead of quarantining on the first all-replica sweep (behaviour unchanged from when this surfaced as QwpDurableAckMismatchException).
1 parent 1570c5a commit d920b9c

2 files changed

Lines changed: 28 additions & 16 deletions

File tree

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

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2567,21 +2567,23 @@ private synchronized WebSocketClient buildAndConnect(ReconnectSupplier ctx) {
25672567
throw terminalUpgradeError;
25682568
}
25692569
if (lastRoleReject != null) {
2570-
// When the client opted into durable ack but every endpoint
2571-
// role-rejected the /write/v4 upgrade (typically a misconfigured
2572-
// address list pointing at replicas only), a primary that can
2573-
// serve durable ack will not appear by retrying. Throw the typed
2574-
// QwpDurableAckMismatchException -- the cursor send loop's terminal
2575-
// classifier recognises it by instanceof and suppresses retry, so
2576-
// the SYNC/ASYNC connect paths fail fast instead of burning the
2577-
// full reconnect_max_duration_millis budget walking the same
2578-
// replicas.
2579-
if (requestDurableAck) {
2580-
QwpDurableAckMismatchException ackErr = new QwpDurableAckMismatchException(
2581-
lastRoleReject.getHost(), lastRoleReject.getPort(), lastRoleReject.getRole());
2582-
ackErr.initCause(lastRoleReject);
2583-
throw ackErr;
2584-
}
2570+
// Every endpoint role-rejected the /write/v4 upgrade: right now the
2571+
// reachable nodes are all replicas (or primary-catchup). That is a
2572+
// TRANSIENT failover window, not a terminal condition -- a replica
2573+
// can be promoted and a primary will reappear. Surface it as a
2574+
// retriable QwpRoleMismatchException so the SYNC/ASYNC connect and
2575+
// reconnect loops keep the rows in store-and-forward and retry
2576+
// within reconnect_max_duration_millis (for an SF sender the only
2577+
// terminal condition is SF exhaustion).
2578+
//
2579+
// This holds even when durable ack was requested: a replica that
2580+
// gets promoted serves durable ack, so an all-replica window must
2581+
// NOT be reported as a durable-ack mismatch. Doing so conflated a
2582+
// transient role state with a permanent capability gap and hard-
2583+
// failed HA senders that should have recovered on promotion. A
2584+
// genuine capability gap -- an endpoint that upgrades but does not
2585+
// advertise durable ack -- is still terminal: it is raised as
2586+
// terminalUpgradeError above, before this block.
25852587
QwpRoleMismatchException ex = new QwpRoleMismatchException(
25862588
QwpIngressRoleRejectedException.ROLE_PRIMARY,
25872589
null,

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626

2727
import io.questdb.client.cutlass.http.client.WebSocketClient;
2828
import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
29+
import io.questdb.client.cutlass.qwp.client.QwpIngressRoleRejectedException;
30+
import io.questdb.client.cutlass.qwp.client.QwpRoleMismatchException;
2931
import org.jetbrains.annotations.TestOnly;
3032
import org.slf4j.Logger;
3133
import org.slf4j.LoggerFactory;
@@ -163,7 +165,15 @@ public WebSocketClient connectWithDurableAckRetry() {
163165
while (!stopRequested) {
164166
try {
165167
return clientFactory.reconnect();
166-
} catch (QwpDurableAckMismatchException e) {
168+
} catch (QwpDurableAckMismatchException | QwpRoleMismatchException
169+
| QwpIngressRoleRejectedException e) {
170+
// An all-replica window (every endpoint role-rejected the
171+
// upgrade) is handled exactly like a durable-ack-unavailable
172+
// cluster: give it a budget to settle, then quarantine. A
173+
// replica can be promoted, so the drainer must not quarantine an
174+
// orphaned slot on the first sweep just because no primary is
175+
// reachable yet. Behaviour is unchanged from when buildAndConnect
176+
// surfaced this same condition as QwpDurableAckMismatchException.
167177
mismatchAttempts++;
168178
long now = System.nanoTime();
169179
long elapsedMs = (now - startNanos) / 1_000_000L;

0 commit comments

Comments
 (0)