diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java
index 645d7b25..b996245c 100644
--- a/core/src/main/java/io/questdb/client/Sender.java
+++ b/core/src/main/java/io/questdb/client/Sender.java
@@ -34,6 +34,7 @@
import io.questdb.client.cutlass.line.http.AbstractLineHttpSender;
import io.questdb.client.cutlass.line.tcp.DelegatingTlsChannel;
import io.questdb.client.cutlass.line.tcp.PlainTcpLineChannel;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.qwp.client.QwpUdpSender;
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
@@ -1145,7 +1146,7 @@ public int getConnectTimeout() {
// max backoff (default 5_000) for the cursor I/O loop's exponential
// retry-with-jitter loop.
private long reconnectMaxDurationMillis = PARAMETER_NOT_SET_EXPLICITLY;
- private boolean requestDurableAck;
+ private int durableAckTiers = DurableAckTiers.NONE;
private int retryTimeoutMillis = PARAMETER_NOT_SET_EXPLICITLY;
private boolean transactional;
private String senderId = DEFAULT_SENDER_ID;
@@ -1698,7 +1699,7 @@ public Sender build() {
actualAutoFlushBytes,
actualAutoFlushIntervalNanos,
wsAuthHeader,
- requestDurableAck,
+ durableAckTiers,
cursorEngine,
actualCloseFlushTimeoutMillis,
actualReconnectMaxDurationMillis,
@@ -2779,9 +2780,11 @@ public LineSenderBuilder reconnectMaxDurationMillis(long millis) {
}
/**
- * Opts the connection in for STATUS_DURABLE_ACK frames. When enabled,
- * servers with primary replication will emit per-table durable-upload
- * watermarks as WAL data reaches the object store.
+ * Opts the connection in for STATUS_DURABLE_ACK frames, using the
+ * legacy "true" request token. Equivalent to
+ * {@code requestDurableAck("on")}: requests the replicated tier, so
+ * servers without primary replication deny the request and the
+ * sender fails at connect.
*
* This setting is only supported for WebSocket transport.
*
@@ -2789,10 +2792,48 @@ public LineSenderBuilder reconnectMaxDurationMillis(long millis) {
* @return this instance for method chaining
*/
public LineSenderBuilder requestDurableAck(boolean enabled) {
+ return requestDurableAckTiers(enabled
+ ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE
+ : DurableAckTiers.NONE);
+ }
+
+ /**
+ * Requests durable-ack streams by tier set. Accepted values:
+ * {@code off}, {@code on} (legacy alias for the replicated tier),
+ * {@code local}, {@code replicated}, {@code local,replicated}.
+ *
+ * - {@code local} -- the server emits STATUS_LOCAL_DURABLE_ACK
+ * frames once commits are fdatasync-durable on its disk; the
+ * sender trims its store-and-forward copy on them.
+ * - {@code replicated} -- the server emits STATUS_DURABLE_ACK
+ * frames once commits reach the object store; the sender trims
+ * on them.
+ * - {@code local,replicated} -- both streams; the sender trims on
+ * the replicated ack (the strongest requested guarantee) and
+ * receives local acks as early progress signals.
+ *
+ * The server grants the full requested set or denies the request
+ * entirely (the sender then fails at connect); it never substitutes
+ * a weaker guarantee.
+ *
+ * This setting is only supported for WebSocket transport.
+ *
+ * @param tiers the requested tier set
+ * @return this instance for method chaining
+ */
+ public LineSenderBuilder requestDurableAck(CharSequence tiers) {
+ int parsed = DurableAckTiers.parseConfigValue(tiers);
+ if (parsed < 0) {
+ throw new LineSenderException("invalid request_durable_ack [value=").put(tiers).put(", allowed-values=[on, off, local, replicated, local,replicated]]");
+ }
+ return requestDurableAckTiers(parsed);
+ }
+
+ private LineSenderBuilder requestDurableAckTiers(int tiers) {
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
throw new LineSenderException("request_durable_ack is only supported for WebSocket transport");
}
- this.requestDurableAck = enabled;
+ this.durableAckTiers = tiers;
return this;
}
@@ -3762,13 +3803,11 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
throw new LineSenderException("request_durable_ack is only supported for WebSocket transport");
}
pos = getValue(configurationString, pos, sink, "request_durable_ack");
- if (Chars.equalsIgnoreCase("on", sink)) {
- requestDurableAck(true);
- } else if (Chars.equalsIgnoreCase("off", sink)) {
- requestDurableAck(false);
- } else {
- throw new LineSenderException("invalid request_durable_ack [value=").put(sink).put(", allowed-values=[on, off]]");
+ int tiers = DurableAckTiers.parseConfigValue(sink);
+ if (tiers < 0) {
+ throw new LineSenderException("invalid request_durable_ack [value=").put(sink).put(", allowed-values=[on, off, local, replicated, local,replicated]]");
}
+ requestDurableAckTiers(tiers);
} else if (Chars.equals("transaction", sink)) {
if (protocol != PROTOCOL_WEBSOCKET) {
throw new LineSenderException("transaction is only supported for WebSocket transport");
@@ -4180,13 +4219,11 @@ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString)
}
s = view.getStr("request_durable_ack");
if (s != null) {
- if (s.equalsIgnoreCase("on")) {
- requestDurableAck(true);
- } else if (s.equalsIgnoreCase("off")) {
- requestDurableAck(false);
- } else {
- throw new LineSenderException("invalid request_durable_ack [value=").put(s).put(", allowed-values=[on, off]]");
+ int tiers = DurableAckTiers.parseConfigValue(s);
+ if (tiers < 0) {
+ throw new LineSenderException("invalid request_durable_ack [value=").put(s).put(", allowed-values=[on, off, local, replicated, local,replicated]]");
}
+ requestDurableAckTiers(tiers);
}
s = view.getStr("drain_orphans");
if (s != null) {
@@ -4306,7 +4343,7 @@ public java.util.Map wsConfigSnapshotForTest() {
m.put("auto_flush_interval", autoFlushIntervalMillis);
m.put("max_name_len", maxNameLength);
m.put("transaction", transactional);
- m.put("request_durable_ack", requestDurableAck);
+ m.put("request_durable_ack", DurableAckTiers.configValue(durableAckTiers));
m.put("sender_id", senderId);
m.put("sf_dir", sfDir);
m.put("sf_max_segment_bytes", sfMaxSegmentBytes);
@@ -4400,7 +4437,7 @@ private void validateParameters() {
.put(", requestedCapacity=").put(bufferCapacity)
.put("]");
}
- if (requestDurableAck && protocol != PROTOCOL_WEBSOCKET) {
+ if (durableAckTiers != DurableAckTiers.NONE && protocol != PROTOCOL_WEBSOCKET) {
throw new LineSenderException("request_durable_ack is only supported for WebSocket transport");
}
if (protocol == PROTOCOL_HTTP) {
diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java
index 1290fcab..81859c47 100644
--- a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java
+++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java
@@ -25,6 +25,7 @@
package io.questdb.client.cutlass.http.client;
import io.questdb.client.HttpClientConfiguration;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.qwp.client.QwpVersionMismatchException;
import io.questdb.client.cutlass.qwp.websocket.WebSocketCloseCode;
import io.questdb.client.cutlass.qwp.websocket.WebSocketFrameParser;
@@ -80,7 +81,6 @@ public abstract class WebSocketClient implements QuietCloseable {
private static final String QUESTDB_ROLE_HEADER_NAME = "X-QuestDB-Role:";
private static final String QUESTDB_ZONE_HEADER_NAME = "X-QuestDB-Zone:";
private static final String QWP_CONTENT_ENCODING_HEADER_NAME = "X-QWP-Content-Encoding:";
- private static final String QWP_DURABLE_ACK_ENABLED_VALUE = "enabled";
private static final String QWP_DURABLE_ACK_HEADER_NAME = "X-QWP-Durable-Ack:";
private static final String QWP_MAX_BATCH_SIZE_HEADER_NAME = "X-QWP-Max-Batch-Size:";
private static final String QWP_VERSION_HEADER_NAME = "X-QWP-Version:";
@@ -137,7 +137,7 @@ public abstract class WebSocketClient implements QuietCloseable {
private int qwpMaxBatchRows;
private int qwpMaxVersion = 1;
// Opt-in for STATUS_DURABLE_ACK frames; sent as X-QWP-Request-Durable-Ack: true
- private boolean qwpRequestDurableAck;
+ private int qwpDurableAckTiers = DurableAckTiers.NONE;
// Receive buffer (native memory)
private long recvBufPtr;
private int recvBufSize;
@@ -586,13 +586,14 @@ public void setQwpMaxVersion(int maxVersion) {
}
/**
- * Enables the opt-in X-QWP-Request-Durable-Ack upgrade header. When set,
- * servers with primary replication configured will additionally emit
- * STATUS_DURABLE_ACK frames as the WAL containing committed client
- * messages reaches the object store.
+ * Sets the requested durable-ack tier set ({@link DurableAckTiers}
+ * bitmask) for the opt-in X-QWP-Request-Durable-Ack upgrade header. When
+ * granted, the server emits STATUS_DURABLE_ACK frames (replicated tier)
+ * and/or STATUS_LOCAL_DURABLE_ACK frames (local tier) as commits reach
+ * the corresponding durability frontier.
*/
- public void setQwpRequestDurableAck(boolean enabled) {
- this.qwpRequestDurableAck = enabled;
+ public void setQwpDurableAckTiers(int tiers) {
+ this.qwpDurableAckTiers = tiers;
}
/**
@@ -694,8 +695,10 @@ public void upgrade(CharSequence path, int timeout, CharSequence authorizationHe
sendBuffer.putAscii(Integer.toString(qwpMaxBatchRows));
sendBuffer.putAscii("\r\n");
}
- if (qwpRequestDurableAck) {
- sendBuffer.putAscii("X-QWP-Request-Durable-Ack: true\r\n");
+ if (qwpDurableAckTiers != DurableAckTiers.NONE) {
+ sendBuffer.putAscii("X-QWP-Request-Durable-Ack: ");
+ sendBuffer.putAscii(DurableAckTiers.requestHeaderValue(qwpDurableAckTiers));
+ sendBuffer.putAscii("\r\n");
}
if (authorizationHeader != null) {
sendBuffer.putAscii("Authorization: ");
@@ -798,7 +801,10 @@ private static int extractContentEncodingZstdLevel(String response) {
return 0;
}
- private static boolean extractDurableAckEnabled(String response) {
+ private static boolean extractDurableAckConfirmed(String response, String expectedToken) {
+ if (expectedToken == null) {
+ return false;
+ }
int headerLen = QWP_DURABLE_ACK_HEADER_NAME.length();
int responseLen = response.length();
for (int i = 0; i <= responseLen - headerLen; i++) {
@@ -809,7 +815,11 @@ private static boolean extractDurableAckEnabled(String response) {
lineEnd = responseLen;
}
String value = response.substring(valueStart, lineEnd).trim();
- return value.equalsIgnoreCase(QWP_DURABLE_ACK_ENABLED_VALUE);
+ // The server echoes the granted set verbatim (or the
+ // "enabled" token for a legacy "true" request); anything
+ // else is a partial or foreign grant and counts as a
+ // denial -- all-or-nothing, never a silent downgrade.
+ return value.equalsIgnoreCase(expectedToken);
}
}
return false;
@@ -1390,7 +1400,8 @@ private void validateUpgradeResponse(int headerEnd) {
// Only meaningful when qwpRequestDurableAck is true; the sender
// checks this value to fail at connect rather than silently
// missing trim signals.
- serverDurableAckEnabled = extractDurableAckEnabled(response);
+ serverDurableAckEnabled = extractDurableAckConfirmed(
+ response, DurableAckTiers.expectedConfirmToken(qwpDurableAckTiers));
// Extract X-QWP-Max-Batch-Size (optional). Older servers omit it; the
// sender falls back to its locally configured byte budget in that case.
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/DurableAckTiers.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/DurableAckTiers.java
new file mode 100644
index 00000000..b3648cbe
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/DurableAckTiers.java
@@ -0,0 +1,162 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2024 QuestDB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ ******************************************************************************/
+
+package io.questdb.client.cutlass.qwp.client;
+
+/**
+ * The QWP durable-ack tier set a sender can request, encoded as a bitmask:
+ *
+ * - {@link #LOCAL} — the server emits {@code STATUS_LOCAL_DURABLE_ACK}
+ * frames once commits are fdatasync-durable on its disk
+ * (power-loss-safe).
+ * - {@link #REPLICATED} — the server emits {@code STATUS_DURABLE_ACK}
+ * frames once commits reach the object store (failover-safe).
+ *
+ * {@link #LEGACY_TRUE} is a modifier bit for requests made through the
+ * boolean {@code requestDurableAck(true)} API or the {@code on} config
+ * value: the request header carries the literal {@code "true"} and the
+ * server confirms the grant with the {@code "enabled"} token. It always
+ * combines with {@link #REPLICATED}.
+ *
+ * The server grants the full requested set or denies the request entirely
+ * (no confirmation header); it never substitutes a weaker guarantee. The
+ * sender trims its store-and-forward copy on the strongest requested tier's
+ * ack; with both tiers requested, local acks arrive as progress signals only.
+ */
+public final class DurableAckTiers {
+
+ public static final int NONE = 0;
+ public static final int LOCAL = 1;
+ public static final int REPLICATED = 2;
+ // Modifier bit, only ever combined with REPLICATED: send the "true"
+ // request token and expect the "enabled" confirmation.
+ public static final int LEGACY_TRUE = 4;
+
+ private DurableAckTiers() {
+ }
+
+ /**
+ * The config-string form of a tier set, the inverse of
+ * {@link #parseConfigValue(CharSequence)} (legacy sets print as "on").
+ */
+ public static String configValue(int tiers) {
+ if ((tiers & LEGACY_TRUE) != 0) {
+ return "on";
+ }
+ switch (tiers & (LOCAL | REPLICATED)) {
+ case LOCAL:
+ return "local";
+ case REPLICATED:
+ return "replicated";
+ case LOCAL | REPLICATED:
+ return "local,replicated";
+ default:
+ return "off";
+ }
+ }
+
+ /**
+ * The X-QWP-Durable-Ack confirmation token the server must echo for this
+ * request, or null when no tier is requested. A legacy request expects
+ * the {@code "enabled"} token; explicit tier requests expect their own
+ * token set back verbatim.
+ */
+ public static String expectedConfirmToken(int tiers) {
+ if ((tiers & LEGACY_TRUE) != 0) {
+ return "enabled";
+ }
+ return explicitToken(tiers);
+ }
+
+ public static boolean hasLocal(int tiers) {
+ return (tiers & LOCAL) != 0;
+ }
+
+ public static boolean hasReplicated(int tiers) {
+ return (tiers & REPLICATED) != 0;
+ }
+
+ /**
+ * True when the sender's trim trigger is {@code STATUS_LOCAL_DURABLE_ACK}:
+ * the local tier is requested without the replicated one. Any request
+ * including the replicated tier trims on {@code STATUS_DURABLE_ACK} — the
+ * strongest requested guarantee wins.
+ */
+ public static boolean isTrimOnLocalAck(int tiers) {
+ return hasLocal(tiers) && !hasReplicated(tiers);
+ }
+
+ /**
+ * Parses a {@code request_durable_ack} value into a tier set, or -1 for
+ * an unrecognized value. {@code on} maps to the replicated tier with
+ * {@link #LEGACY_TRUE} set; {@code off} is {@link #NONE}.
+ */
+ public static int parseConfigValue(CharSequence value) {
+ if (value == null) {
+ return -1;
+ }
+ String v = value.toString().trim();
+ if (v.equalsIgnoreCase("off")) {
+ return NONE;
+ }
+ if (v.equalsIgnoreCase("on")) {
+ return REPLICATED | LEGACY_TRUE;
+ }
+ if (v.equalsIgnoreCase("local")) {
+ return LOCAL;
+ }
+ if (v.equalsIgnoreCase("replicated")) {
+ return REPLICATED;
+ }
+ if (v.equalsIgnoreCase("local,replicated") || v.equalsIgnoreCase("replicated,local")) {
+ return LOCAL | REPLICATED;
+ }
+ return -1;
+ }
+
+ /**
+ * The X-QWP-Request-Durable-Ack header value for a tier set, or null when
+ * no tier is requested. A legacy set sends the literal {@code "true"},
+ * the only request value servers without tier support recognize.
+ */
+ public static String requestHeaderValue(int tiers) {
+ if ((tiers & LEGACY_TRUE) != 0) {
+ return "true";
+ }
+ return explicitToken(tiers);
+ }
+
+ private static String explicitToken(int tiers) {
+ switch (tiers & (LOCAL | REPLICATED)) {
+ case LOCAL:
+ return "local";
+ case REPLICATED:
+ return "replicated";
+ case LOCAL | REPLICATED:
+ return "local,replicated";
+ default:
+ return null;
+ }
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java
index 41cc0a8c..c94e9488 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java
@@ -384,7 +384,7 @@ public class QwpWebSocketSender implements Sender {
// values; Sender.build can override via the new connect overload.
private long reconnectMaxDurationMillis =
CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS;
- private boolean requestDurableAck;
+ private int durableAckTiers;
// Monotonic per-attempt counter snapshotted onto every connection event
// fired from buildAndConnect. Counts every FOREGROUND endpoint try --
// successes and failures alike -- across this sender's lifetime.
@@ -493,7 +493,7 @@ public static QwpWebSocketSender connect(String host, int port, ClientTlsConfigu
host, port, tlsConfig,
DEFAULT_AUTO_FLUSH_ROWS, DEFAULT_AUTO_FLUSH_BYTES, DEFAULT_AUTO_FLUSH_INTERVAL_NANOS,
null,
- false, engine
+ DurableAckTiers.NONE, engine
);
} catch (Throwable t) {
try {
@@ -518,12 +518,12 @@ public static QwpWebSocketSender connect(
int autoFlushBytes,
long autoFlushIntervalNanos,
String authorizationHeader,
- boolean requestDurableAck,
+ int durableAckTiers,
CursorSendEngine cursorEngine
) {
return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos,
authorizationHeader,
- requestDurableAck, cursorEngine, 5_000L);
+ durableAckTiers, cursorEngine, 5_000L);
}
/**
@@ -540,13 +540,13 @@ public static QwpWebSocketSender connect(
int autoFlushBytes,
long autoFlushIntervalNanos,
String authorizationHeader,
- boolean requestDurableAck,
+ int durableAckTiers,
CursorSendEngine cursorEngine,
long closeFlushTimeoutMillis
) {
return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes,
autoFlushIntervalNanos, authorizationHeader,
- requestDurableAck, cursorEngine,
+ durableAckTiers, cursorEngine,
closeFlushTimeoutMillis,
CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS,
CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS,
@@ -567,7 +567,7 @@ public static QwpWebSocketSender connect(
int autoFlushBytes,
long autoFlushIntervalNanos,
String authorizationHeader,
- boolean requestDurableAck,
+ int durableAckTiers,
CursorSendEngine cursorEngine,
long closeFlushTimeoutMillis,
long reconnectMaxDurationMillis,
@@ -576,7 +576,7 @@ public static QwpWebSocketSender connect(
) {
return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes,
autoFlushIntervalNanos, authorizationHeader,
- requestDurableAck, cursorEngine,
+ durableAckTiers, cursorEngine,
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
Sender.InitialConnectMode.OFF);
@@ -597,7 +597,7 @@ public static QwpWebSocketSender connect(
int autoFlushBytes,
long autoFlushIntervalNanos,
String authorizationHeader,
- boolean requestDurableAck,
+ int durableAckTiers,
CursorSendEngine cursorEngine,
long closeFlushTimeoutMillis,
long reconnectMaxDurationMillis,
@@ -607,7 +607,7 @@ public static QwpWebSocketSender connect(
) {
return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes,
autoFlushIntervalNanos, authorizationHeader,
- requestDurableAck, cursorEngine,
+ durableAckTiers, cursorEngine,
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
initialConnectMode, null, SenderErrorDispatcher.DEFAULT_CAPACITY);
@@ -626,7 +626,7 @@ public static QwpWebSocketSender connect(
int autoFlushBytes,
long autoFlushIntervalNanos,
String authorizationHeader,
- boolean requestDurableAck,
+ int durableAckTiers,
CursorSendEngine cursorEngine,
long closeFlushTimeoutMillis,
long reconnectMaxDurationMillis,
@@ -638,7 +638,7 @@ public static QwpWebSocketSender connect(
) {
return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes,
autoFlushIntervalNanos, authorizationHeader,
- requestDurableAck, cursorEngine,
+ durableAckTiers, cursorEngine,
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
initialConnectMode, errorHandler, errorInboxCapacity,
@@ -659,7 +659,7 @@ public static QwpWebSocketSender connect(
int autoFlushBytes,
long autoFlushIntervalNanos,
String authorizationHeader,
- boolean requestDurableAck,
+ int durableAckTiers,
CursorSendEngine cursorEngine,
long closeFlushTimeoutMillis,
long reconnectMaxDurationMillis,
@@ -674,7 +674,7 @@ public static QwpWebSocketSender connect(
singleEndpoint(host, port), tlsConfig,
autoFlushRows, autoFlushBytes, autoFlushIntervalNanos,
authorizationHeader,
- requestDurableAck, cursorEngine,
+ durableAckTiers, cursorEngine,
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
initialConnectMode, errorHandler, errorInboxCapacity,
@@ -697,7 +697,7 @@ public static QwpWebSocketSender connect(
int autoFlushBytes,
long autoFlushIntervalNanos,
String authorizationHeader,
- boolean requestDurableAck,
+ int durableAckTiers,
CursorSendEngine cursorEngine,
long closeFlushTimeoutMillis,
long reconnectMaxDurationMillis,
@@ -711,7 +711,7 @@ public static QwpWebSocketSender connect(
) {
return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes,
autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader),
- requestDurableAck, cursorEngine,
+ durableAckTiers, cursorEngine,
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
initialConnectMode, errorHandler, errorInboxCapacity,
@@ -737,7 +737,7 @@ public static QwpWebSocketSender connect(
int autoFlushBytes,
long autoFlushIntervalNanos,
String authorizationHeader,
- boolean requestDurableAck,
+ int durableAckTiers,
CursorSendEngine cursorEngine,
long closeFlushTimeoutMillis,
long reconnectMaxDurationMillis,
@@ -754,7 +754,7 @@ public static QwpWebSocketSender connect(
) {
return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes,
autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader),
- requestDurableAck, cursorEngine,
+ durableAckTiers, cursorEngine,
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
initialConnectMode, errorHandler, errorInboxCapacity,
@@ -780,7 +780,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier(
int autoFlushBytes,
long autoFlushIntervalNanos,
Supplier authorizationHeaderSupplier,
- boolean requestDurableAck,
+ int durableAckTiers,
CursorSendEngine cursorEngine,
long closeFlushTimeoutMillis,
long reconnectMaxDurationMillis,
@@ -796,7 +796,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier(
int connectionListenerInboxCapacity
) {
return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes,
- autoFlushIntervalNanos, authorizationHeaderSupplier, requestDurableAck,
+ autoFlushIntervalNanos, authorizationHeaderSupplier, durableAckTiers,
cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis,
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
initialConnectMode, errorHandler, errorInboxCapacity,
@@ -825,7 +825,7 @@ public static QwpWebSocketSender connect(
int autoFlushBytes,
long autoFlushIntervalNanos,
String authorizationHeader,
- boolean requestDurableAck,
+ int durableAckTiers,
CursorSendEngine cursorEngine,
long closeFlushTimeoutMillis,
long reconnectMaxDurationMillis,
@@ -845,7 +845,7 @@ public static QwpWebSocketSender connect(
) {
return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes,
autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader),
- requestDurableAck, cursorEngine,
+ durableAckTiers, cursorEngine,
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
initialConnectMode, errorHandler, errorInboxCapacity,
@@ -855,6 +855,366 @@ autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader),
catchUpCapGapMinEscalationWindowMillis);
}
+ // ------------------------------------------------------------------
+ // Boolean durable-ack overloads. Each maps its boolean flag onto the
+ // DurableAckTiers bitmask the master entry point takes: true becomes
+ // the legacy request (REPLICATED | LEGACY_TRUE -- header value "true",
+ // confirmed by the "enabled" token), false becomes NONE. They keep
+ // binary compatibility for callers linked against the boolean
+ // signatures.
+ // ------------------------------------------------------------------
+
+ public static QwpWebSocketSender connect(
+ String host,
+ int port,
+ ClientTlsConfiguration tlsConfig,
+ int autoFlushRows,
+ int autoFlushBytes,
+ long autoFlushIntervalNanos,
+ String authorizationHeader,
+ boolean requestDurableAck,
+ CursorSendEngine cursorEngine
+ ) {
+ return connect(
+ host,
+ port,
+ tlsConfig,
+ autoFlushRows,
+ autoFlushBytes,
+ autoFlushIntervalNanos,
+ authorizationHeader,
+ requestDurableAck
+ ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE
+ : DurableAckTiers.NONE,
+ cursorEngine
+ );
+ }
+
+ public static QwpWebSocketSender connect(
+ String host,
+ int port,
+ ClientTlsConfiguration tlsConfig,
+ int autoFlushRows,
+ int autoFlushBytes,
+ long autoFlushIntervalNanos,
+ String authorizationHeader,
+ boolean requestDurableAck,
+ CursorSendEngine cursorEngine,
+ long closeFlushTimeoutMillis
+ ) {
+ return connect(
+ host,
+ port,
+ tlsConfig,
+ autoFlushRows,
+ autoFlushBytes,
+ autoFlushIntervalNanos,
+ authorizationHeader,
+ requestDurableAck
+ ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE
+ : DurableAckTiers.NONE,
+ cursorEngine,
+ closeFlushTimeoutMillis
+ );
+ }
+
+ public static QwpWebSocketSender connect(
+ String host,
+ int port,
+ ClientTlsConfiguration tlsConfig,
+ int autoFlushRows,
+ int autoFlushBytes,
+ long autoFlushIntervalNanos,
+ String authorizationHeader,
+ boolean requestDurableAck,
+ CursorSendEngine cursorEngine,
+ long closeFlushTimeoutMillis,
+ long reconnectMaxDurationMillis,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis
+ ) {
+ return connect(
+ host,
+ port,
+ tlsConfig,
+ autoFlushRows,
+ autoFlushBytes,
+ autoFlushIntervalNanos,
+ authorizationHeader,
+ requestDurableAck
+ ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE
+ : DurableAckTiers.NONE,
+ cursorEngine,
+ closeFlushTimeoutMillis,
+ reconnectMaxDurationMillis,
+ reconnectInitialBackoffMillis,
+ reconnectMaxBackoffMillis
+ );
+ }
+
+ public static QwpWebSocketSender connect(
+ String host,
+ int port,
+ ClientTlsConfiguration tlsConfig,
+ int autoFlushRows,
+ int autoFlushBytes,
+ long autoFlushIntervalNanos,
+ String authorizationHeader,
+ boolean requestDurableAck,
+ CursorSendEngine cursorEngine,
+ long closeFlushTimeoutMillis,
+ long reconnectMaxDurationMillis,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis,
+ Sender.InitialConnectMode initialConnectMode
+ ) {
+ return connect(
+ host,
+ port,
+ tlsConfig,
+ autoFlushRows,
+ autoFlushBytes,
+ autoFlushIntervalNanos,
+ authorizationHeader,
+ requestDurableAck
+ ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE
+ : DurableAckTiers.NONE,
+ cursorEngine,
+ closeFlushTimeoutMillis,
+ reconnectMaxDurationMillis,
+ reconnectInitialBackoffMillis,
+ reconnectMaxBackoffMillis,
+ initialConnectMode
+ );
+ }
+
+ public static QwpWebSocketSender connect(
+ String host,
+ int port,
+ ClientTlsConfiguration tlsConfig,
+ int autoFlushRows,
+ int autoFlushBytes,
+ long autoFlushIntervalNanos,
+ String authorizationHeader,
+ boolean requestDurableAck,
+ CursorSendEngine cursorEngine,
+ long closeFlushTimeoutMillis,
+ long reconnectMaxDurationMillis,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis,
+ Sender.InitialConnectMode initialConnectMode,
+ SenderErrorHandler errorHandler,
+ int errorInboxCapacity
+ ) {
+ return connect(
+ host,
+ port,
+ tlsConfig,
+ autoFlushRows,
+ autoFlushBytes,
+ autoFlushIntervalNanos,
+ authorizationHeader,
+ requestDurableAck
+ ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE
+ : DurableAckTiers.NONE,
+ cursorEngine,
+ closeFlushTimeoutMillis,
+ reconnectMaxDurationMillis,
+ reconnectInitialBackoffMillis,
+ reconnectMaxBackoffMillis,
+ initialConnectMode,
+ errorHandler,
+ errorInboxCapacity
+ );
+ }
+
+ public static QwpWebSocketSender connect(
+ String host,
+ int port,
+ ClientTlsConfiguration tlsConfig,
+ int autoFlushRows,
+ int autoFlushBytes,
+ long autoFlushIntervalNanos,
+ String authorizationHeader,
+ boolean requestDurableAck,
+ CursorSendEngine cursorEngine,
+ long closeFlushTimeoutMillis,
+ long reconnectMaxDurationMillis,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis,
+ Sender.InitialConnectMode initialConnectMode,
+ SenderErrorHandler errorHandler,
+ int errorInboxCapacity,
+ long durableAckKeepaliveIntervalMillis
+ ) {
+ return connect(
+ host,
+ port,
+ tlsConfig,
+ autoFlushRows,
+ autoFlushBytes,
+ autoFlushIntervalNanos,
+ authorizationHeader,
+ requestDurableAck
+ ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE
+ : DurableAckTiers.NONE,
+ cursorEngine,
+ closeFlushTimeoutMillis,
+ reconnectMaxDurationMillis,
+ reconnectInitialBackoffMillis,
+ reconnectMaxBackoffMillis,
+ initialConnectMode,
+ errorHandler,
+ errorInboxCapacity,
+ durableAckKeepaliveIntervalMillis
+ );
+ }
+
+ public static QwpWebSocketSender connect(
+ List endpoints,
+ ClientTlsConfiguration tlsConfig,
+ int autoFlushRows,
+ int autoFlushBytes,
+ long autoFlushIntervalNanos,
+ String authorizationHeader,
+ boolean requestDurableAck,
+ CursorSendEngine cursorEngine,
+ long closeFlushTimeoutMillis,
+ long reconnectMaxDurationMillis,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis,
+ Sender.InitialConnectMode initialConnectMode,
+ SenderErrorHandler errorHandler,
+ int errorInboxCapacity,
+ long durableAckKeepaliveIntervalMillis,
+ long authTimeoutMs
+ ) {
+ return connect(
+ endpoints,
+ tlsConfig,
+ autoFlushRows,
+ autoFlushBytes,
+ autoFlushIntervalNanos,
+ authorizationHeader,
+ requestDurableAck
+ ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE
+ : DurableAckTiers.NONE,
+ cursorEngine,
+ closeFlushTimeoutMillis,
+ reconnectMaxDurationMillis,
+ reconnectInitialBackoffMillis,
+ reconnectMaxBackoffMillis,
+ initialConnectMode,
+ errorHandler,
+ errorInboxCapacity,
+ durableAckKeepaliveIntervalMillis,
+ authTimeoutMs
+ );
+ }
+
+ public static QwpWebSocketSender connect(
+ List endpoints,
+ ClientTlsConfiguration tlsConfig,
+ int autoFlushRows,
+ int autoFlushBytes,
+ long autoFlushIntervalNanos,
+ String authorizationHeader,
+ boolean requestDurableAck,
+ CursorSendEngine cursorEngine,
+ long closeFlushTimeoutMillis,
+ long reconnectMaxDurationMillis,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis,
+ Sender.InitialConnectMode initialConnectMode,
+ SenderErrorHandler errorHandler,
+ int errorInboxCapacity,
+ long durableAckKeepaliveIntervalMillis,
+ long authTimeoutMs,
+ int connectTimeoutMs,
+ SenderConnectionListener connectionListener,
+ int connectionListenerInboxCapacity
+ ) {
+ return connect(
+ endpoints,
+ tlsConfig,
+ autoFlushRows,
+ autoFlushBytes,
+ autoFlushIntervalNanos,
+ authorizationHeader,
+ requestDurableAck
+ ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE
+ : DurableAckTiers.NONE,
+ cursorEngine,
+ closeFlushTimeoutMillis,
+ reconnectMaxDurationMillis,
+ reconnectInitialBackoffMillis,
+ reconnectMaxBackoffMillis,
+ initialConnectMode,
+ errorHandler,
+ errorInboxCapacity,
+ durableAckKeepaliveIntervalMillis,
+ authTimeoutMs,
+ connectTimeoutMs,
+ connectionListener,
+ connectionListenerInboxCapacity
+ );
+ }
+
+ public static QwpWebSocketSender connect(
+ List endpoints,
+ ClientTlsConfiguration tlsConfig,
+ int autoFlushRows,
+ int autoFlushBytes,
+ long autoFlushIntervalNanos,
+ String authorizationHeader,
+ boolean requestDurableAck,
+ CursorSendEngine cursorEngine,
+ long closeFlushTimeoutMillis,
+ long reconnectMaxDurationMillis,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis,
+ Sender.InitialConnectMode initialConnectMode,
+ SenderErrorHandler errorHandler,
+ int errorInboxCapacity,
+ long durableAckKeepaliveIntervalMillis,
+ long authTimeoutMs,
+ int connectTimeoutMs,
+ SenderConnectionListener connectionListener,
+ int connectionListenerInboxCapacity,
+ int maxFrameRejections,
+ long poisonMinEscalationWindowMillis,
+ long catchUpCapGapMinEscalationWindowMillis
+ ) {
+ return connect(
+ endpoints,
+ tlsConfig,
+ autoFlushRows,
+ autoFlushBytes,
+ autoFlushIntervalNanos,
+ authorizationHeader,
+ requestDurableAck
+ ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE
+ : DurableAckTiers.NONE,
+ cursorEngine,
+ closeFlushTimeoutMillis,
+ reconnectMaxDurationMillis,
+ reconnectInitialBackoffMillis,
+ reconnectMaxBackoffMillis,
+ initialConnectMode,
+ errorHandler,
+ errorInboxCapacity,
+ durableAckKeepaliveIntervalMillis,
+ authTimeoutMs,
+ connectTimeoutMs,
+ connectionListener,
+ connectionListenerInboxCapacity,
+ maxFrameRejections,
+ poisonMinEscalationWindowMillis,
+ catchUpCapGapMinEscalationWindowMillis
+ );
+ }
+
+
/**
* Master connect entry point — also accepts the poison-frame detector
* threshold ({@code max_frame_rejections}): consecutive server-active
@@ -873,7 +1233,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier(
int autoFlushBytes,
long autoFlushIntervalNanos,
Supplier authorizationHeaderSupplier,
- boolean requestDurableAck,
+ int durableAckTiers,
CursorSendEngine cursorEngine,
long closeFlushTimeoutMillis,
long reconnectMaxDurationMillis,
@@ -897,7 +1257,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier(
authorizationHeaderSupplier
);
try {
- sender.requestDurableAck = requestDurableAck;
+ sender.durableAckTiers = durableAckTiers;
sender.authTimeoutMs = authTimeoutMs;
sender.connectTimeoutMs = connectTimeoutMs;
sender.closeFlushTimeoutMillis = closeFlushTimeoutMillis;
@@ -1511,6 +1871,15 @@ public boolean isCloseCleanupComplete() {
return closeCleanupComplete;
}
+ /**
+ * The store-and-forward send loop, for asserting ack/trim counters in
+ * integration tests. Null until the first connect.
+ */
+ @TestOnly
+ public CursorWebSocketSendLoop cursorSendLoopForTest() {
+ return cursorSendLoop;
+ }
+
/**
* True once the store-and-forward slot flock has been released. False
* means an I/O or manager worker did not stop and close() retained the
@@ -2875,7 +3244,7 @@ public synchronized void startOrphanDrainers(
reconnectMaxDurationMillis,
reconnectInitialBackoffMillis,
reconnectMaxBackoffMillis,
- requestDurableAck,
+ durableAckTiers,
durableAckKeepaliveIntervalMillis,
maxFrameRejections,
poisonMinEscalationWindowMillis,
@@ -3371,7 +3740,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx, CursorWebSocketSendLo
try {
newClient.setQwpMaxVersion(QwpConstants.VERSION);
newClient.setQwpClientId(QwpConstants.CLIENT_ID);
- newClient.setQwpRequestDurableAck(requestDurableAck);
+ newClient.setQwpDurableAckTiers(durableAckTiers);
newClient.setConnectTimeout(effectiveConnectTimeoutMs(background, connectTimeoutMs));
if (cancellation != null) {
// Publish the client we are about to block on so a
@@ -3499,7 +3868,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx, CursorWebSocketSendLo
// rethrow. close() is CAS-gated, so re-closing after the
// durable-ack arm's own close is a no-op.
try {
- if (requestDurableAck && !newClient.isServerDurableAckEnabled()) {
+ if (durableAckTiers != DurableAckTiers.NONE && !newClient.isServerDurableAckEnabled()) {
newClient.close();
hostTracker.recordRoleReject(idx, false, !background);
QwpDurableAckMismatchException ackErr = new QwpDurableAckMismatchException(
@@ -4065,7 +4434,7 @@ private void ensureConnected() {
reconnectFactory,
reconnectInitialBackoffMillis,
reconnectMaxBackoffMillis,
- requestDurableAck,
+ durableAckTiers,
durableAckKeepaliveIntervalMillis,
maxFrameRejections,
poisonMinEscalationWindowMillis,
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java
index 81d59d28..9b6cb8e2 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java
@@ -45,7 +45,7 @@
* +--------+----------+------------+--------------------------------------+
*
*
- * STATUS_DURABLE_ACK response format:
+ * STATUS_DURABLE_ACK / STATUS_LOCAL_DURABLE_ACK response format:
*
* +--------+------------+--------------------------------------+
* | status | tableCount | table entries |
@@ -80,6 +80,14 @@ public class WebSocketResponse {
* entries (nameLen + name + seqTxn).
*/
public static final byte STATUS_DURABLE_ACK = 0x02;
+ /**
+ * Per-table local-durability acknowledgment. Emitted when the
+ * connection's granted durable-ack tier set includes {@code local}, as
+ * the tables' sequencer records are fdatasync'd on the server -- the
+ * acked transactions survive power loss, though not the loss of the
+ * server's disk. Same payload layout as {@link #STATUS_DURABLE_ACK}.
+ */
+ public static final byte STATUS_LOCAL_DURABLE_ACK = 0x0E;
public static final byte STATUS_INTERNAL_ERROR = 0x06;
/**
* Node cannot serve writes (read-only replica / demoting primary). Reserved:
@@ -127,6 +135,19 @@ public static WebSocketResponse durableAck(String tableName, long seqTxn) {
return response;
}
+ /**
+ * Creates a local-durability ACK response with a single table entry.
+ */
+ @TestOnly
+ public static WebSocketResponse localDurableAck(String tableName, long seqTxn) {
+ WebSocketResponse response = new WebSocketResponse();
+ response.status = STATUS_LOCAL_DURABLE_ACK;
+ response.sequence = -1;
+ response.tableNames.add(tableName);
+ response.tableSeqTxns.add(seqTxn);
+ return response;
+ }
+
/**
* Creates an error response.
*/
@@ -160,7 +181,7 @@ public static boolean isStructurallyValid(long ptr, int length) {
return validateTableEntries(ptr + 9, length - 9);
}
- if (status == STATUS_DURABLE_ACK) {
+ if (isDurableAckStatus(status)) {
if (length < MIN_DURABLE_ACK_SIZE) {
return false;
}
@@ -218,6 +239,8 @@ public String getStatusName() {
return "OK";
case STATUS_DURABLE_ACK:
return "DURABLE_ACK";
+ case STATUS_LOCAL_DURABLE_ACK:
+ return "LOCAL_DURABLE_ACK";
case STATUS_PARSE_ERROR:
return "PARSE_ERROR";
case STATUS_SCHEMA_MISMATCH:
@@ -256,6 +279,20 @@ public boolean isDurableAck() {
return status == STATUS_DURABLE_ACK;
}
+ /**
+ * Returns true when this is a per-table local-durability ACK
+ * (STATUS_LOCAL_DURABLE_ACK).
+ */
+ public boolean isLocalDurableAck() {
+ return status == STATUS_LOCAL_DURABLE_ACK;
+ }
+
+ // Both durable-ack statuses share the sequence-less payload layout:
+ // status + tableCount + per-table entries.
+ private static boolean isDurableAckStatus(byte status) {
+ return status == STATUS_DURABLE_ACK || status == STATUS_LOCAL_DURABLE_ACK;
+ }
+
/**
* Returns true if this is a success response (STATUS_OK).
*/
@@ -290,7 +327,7 @@ public boolean readFrom(long ptr, int length) {
return readTableEntries(ptr + 9, length - 9);
}
- if (status == STATUS_DURABLE_ACK) {
+ if (isDurableAckStatus(status)) {
if (length < MIN_DURABLE_ACK_SIZE) {
return false;
}
@@ -333,7 +370,7 @@ public int serializedSize() {
if (status == STATUS_OK) {
return MIN_OK_RESPONSE_SIZE + tableEntriesSize();
}
- if (status == STATUS_DURABLE_ACK) {
+ if (isDurableAckStatus(status)) {
return MIN_DURABLE_ACK_SIZE + tableEntriesSize();
}
return MIN_ERROR_RESPONSE_SIZE + getErrorMessageUtf8Length();
@@ -343,8 +380,8 @@ public int serializedSize() {
public String toString() {
if (isSuccess()) {
return "WebSocketResponse{status=OK, seq=" + sequence + ", tables=" + tableNames.size() + "}";
- } else if (isDurableAck()) {
- return "WebSocketResponse{status=DURABLE_ACK, tables=" + tableNames.size() + "}";
+ } else if (isDurableAck() || isLocalDurableAck()) {
+ return "WebSocketResponse{status=" + getStatusName() + ", tables=" + tableNames.size() + "}";
} else {
return "WebSocketResponse{status=" + getStatusName() + ", seq=" + sequence +
", error=" + errorMessage + "}";
@@ -368,7 +405,7 @@ public int writeTo(long ptr) {
Unsafe.getUnsafe().putLong(ptr + offset, sequence);
offset += 8;
offset += writeTableEntries(ptr + offset);
- } else if (status == STATUS_DURABLE_ACK) {
+ } else if (isDurableAckStatus(status)) {
offset += writeTableEntries(ptr + offset);
} else {
Unsafe.getUnsafe().putLong(ptr + offset, sequence);
@@ -482,7 +519,7 @@ private int writeTableEntries(long ptr) {
}
private int getErrorMessageUtf8Length() {
- if (status == STATUS_OK || status == STATUS_DURABLE_ACK || errorMessage == null || errorMessage.isEmpty()) {
+ if (status == STATUS_OK || isDurableAckStatus(status) || errorMessage == null || errorMessage.isEmpty()) {
errorMessageUtf8Length = 0;
return 0;
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java
index facd872b..57bc08f3 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java
@@ -28,6 +28,7 @@
import io.questdb.client.SenderErrorHandler;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketUpgradeException;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.qwp.client.QwpAuthFailedException;
import io.questdb.client.cutlass.qwp.client.QwpCredentialUnavailableException;
import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
@@ -174,7 +175,7 @@ public final class BackgroundDrainer implements Runnable {
private final long reconnectInitialBackoffMillis;
private final long reconnectMaxBackoffMillis;
private final long reconnectMaxDurationMillis;
- private final boolean requestDurableAck;
+ private final int durableAckTiers;
private final long segmentSizeBytes;
private final long sfMaxTotalBytes;
private final String slotPath;
@@ -267,12 +268,12 @@ public BackgroundDrainer(
long reconnectMaxDurationMillis,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
- boolean requestDurableAck,
+ int durableAckTiers,
long durableAckKeepaliveIntervalMillis
) {
this(slotPath, segmentSizeBytes, sfMaxTotalBytes, clientFactory,
reconnectMaxDurationMillis, reconnectInitialBackoffMillis,
- reconnectMaxBackoffMillis, requestDurableAck,
+ reconnectMaxBackoffMillis, durableAckTiers,
durableAckKeepaliveIntervalMillis,
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS,
@@ -293,7 +294,7 @@ public BackgroundDrainer(
long reconnectMaxDurationMillis,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
- boolean requestDurableAck,
+ int durableAckTiers,
long durableAckKeepaliveIntervalMillis,
int maxHeadFrameRejections,
long poisonMinEscalationWindowMillis,
@@ -301,7 +302,7 @@ public BackgroundDrainer(
) {
this(slotPath, segmentSizeBytes, sfMaxTotalBytes, 0L, clientFactory,
reconnectMaxDurationMillis, reconnectInitialBackoffMillis,
- reconnectMaxBackoffMillis, requestDurableAck,
+ reconnectMaxBackoffMillis, durableAckTiers,
durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
poisonMinEscalationWindowMillis,
catchUpCapGapMinEscalationWindowMillis);
@@ -320,7 +321,7 @@ public BackgroundDrainer(
long reconnectMaxDurationMillis,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
- boolean requestDurableAck,
+ int durableAckTiers,
long durableAckKeepaliveIntervalMillis,
int maxHeadFrameRejections,
long poisonMinEscalationWindowMillis,
@@ -334,7 +335,7 @@ public BackgroundDrainer(
this.reconnectMaxDurationMillis = reconnectMaxDurationMillis;
this.reconnectInitialBackoffMillis = reconnectInitialBackoffMillis;
this.reconnectMaxBackoffMillis = reconnectMaxBackoffMillis;
- this.requestDurableAck = requestDurableAck;
+ this.durableAckTiers = durableAckTiers;
this.durableAckKeepaliveIntervalMillis = durableAckKeepaliveIntervalMillis;
this.maxHeadFrameRejections = maxHeadFrameRejections;
this.poisonMinEscalationWindowMillis = poisonMinEscalationWindowMillis;
@@ -350,7 +351,7 @@ public BackgroundDrainer(
*/
@TestOnly
public BackgroundDrainer() {
- this(null, 0L, 0L, null, 0L, 0L, 0L, false, 0L,
+ this(null, 0L, 0L, null, 0L, 0L, 0L, DurableAckTiers.NONE, 0L,
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, 0L, 0L);
}
@@ -1135,7 +1136,7 @@ public void run() {
clientFactory,
reconnectInitialBackoffMillis,
reconnectMaxBackoffMillis,
- requestDurableAck,
+ durableAckTiers,
durableAckKeepaliveIntervalMillis,
maxHeadFrameRejections,
poisonMinEscalationWindowMillis,
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java
index 6643bf03..b13333fa 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java
@@ -34,6 +34,7 @@
import io.questdb.client.cutlass.qwp.client.NativeBufferWriter;
import io.questdb.client.cutlass.qwp.client.QwpAuthFailedException;
import io.questdb.client.cutlass.qwp.client.QwpCredentialUnavailableException;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
import io.questdb.client.cutlass.qwp.client.QwpIngressRoleRejectedException;
import io.questdb.client.cutlass.qwp.client.QwpRoleMismatchException;
@@ -256,6 +257,11 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
// (default), the loop trims on OK as it always has and ignores any
// STATUS_DURABLE_ACK frames that might still arrive (logs a warning).
private final boolean durableAckMode;
+ // True when the local tier is requested without the replicated one: the
+ // trim trigger is then STATUS_LOCAL_DURABLE_ACK. Any request including
+ // the replicated tier trims on STATUS_DURABLE_ACK (strongest requested
+ // wins) and treats local acks as progress signals only.
+ private final boolean isLocalAckTrimming;
// Per-table cumulative durable-upload watermarks, populated only when
// durableAckMode is true. Updated from STATUS_DURABLE_ACK frame entries
// (each entry is monotonically non-decreasing per spec). Reset on every
@@ -263,6 +269,11 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
// by the server -- holding stale watermarks across the wire boundary
// would falsely advance trim before re-confirmation.
private final CharSequenceLongHashMap durableTableWatermarks = new CharSequenceLongHashMap();
+ // Per-table local-fsync watermarks from STATUS_LOCAL_DURABLE_ACK frames
+ // when BOTH tiers are requested. Progress observability only -- the trim
+ // path never reads it (the replicated ack drives the trim). In local-only
+ // mode local acks feed durableTableWatermarks directly instead.
+ private final CharSequenceLongHashMap localDurableTableWatermarks = new CharSequenceLongHashMap();
// Pre-converted to nanos. Consulted only by the orphan terminal policy. Zero disables
// the dwell entirely (count-only escalation at MAX_CATCHUP_CAP_GAP_ATTEMPTS); the
// user-facing 5-minute default is applied at the config layer.
@@ -299,6 +310,7 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
// Counters for observability of the durable-ack path. Both are zero
// when durableAckMode is false.
private final AtomicLong totalDurableAcks = new AtomicLong();
+ private final AtomicLong totalLocalDurableAcks = new AtomicLong();
private final AtomicLong totalDurableTrimAdvances = new AtomicLong();
// Cumulative count of frames the loop has re-sent during post-reconnect
// catch-up windows. Bumped once per frame on every iteration that
@@ -603,13 +615,14 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
long reconnectMaxBackoffMillis) {
this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
reconnectInitialBackoffMillis,
- reconnectMaxBackoffMillis, false);
+ reconnectMaxBackoffMillis, DurableAckTiers.NONE);
}
/**
* Same as the seven-arg constructor but with explicit control over
- * durable-ack-driven trim. {@code durableAckMode = true} switches the loop
- * to trim only on {@link WebSocketResponse#STATUS_DURABLE_ACK} frames; OK
+ * durable-ack-driven trim. A non-empty {@code durableAckTiers} set
+ * ({@link DurableAckTiers}) switches the loop to trim only on the
+ * strongest requested tier's ack frames; OK
* frames are queued until their per-table seqTxns are covered by a durable
* watermark. The default (false) preserves the historical OK-driven trim
* and ignores any durable-ack frames that arrive (logging a warning, since
@@ -620,10 +633,10 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
ReconnectFactory reconnectFactory,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
- boolean durableAckMode) {
+ int durableAckTiers) {
this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
reconnectInitialBackoffMillis,
- reconnectMaxBackoffMillis, durableAckMode,
+ reconnectMaxBackoffMillis, durableAckTiers,
DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS);
}
@@ -639,11 +652,11 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
ReconnectFactory reconnectFactory,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
- boolean durableAckMode,
+ int durableAckTiers,
long durableAckKeepaliveIntervalMillis) {
this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
reconnectInitialBackoffMillis,
- reconnectMaxBackoffMillis, durableAckMode,
+ reconnectMaxBackoffMillis, durableAckTiers,
durableAckKeepaliveIntervalMillis, DEFAULT_MAX_HEAD_FRAME_REJECTIONS);
}
@@ -661,12 +674,12 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
ReconnectFactory reconnectFactory,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
- boolean durableAckMode,
+ int durableAckTiers,
long durableAckKeepaliveIntervalMillis,
int maxHeadFrameRejections) {
this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
reconnectInitialBackoffMillis,
- reconnectMaxBackoffMillis, durableAckMode,
+ reconnectMaxBackoffMillis, durableAckTiers,
durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, 0L);
}
@@ -681,13 +694,13 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
ReconnectFactory reconnectFactory,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
- boolean durableAckMode,
+ int durableAckTiers,
long durableAckKeepaliveIntervalMillis,
int maxHeadFrameRejections,
long poisonMinEscalationWindowMillis) {
this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
reconnectInitialBackoffMillis,
- reconnectMaxBackoffMillis, durableAckMode,
+ reconnectMaxBackoffMillis, durableAckTiers,
durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
poisonMinEscalationWindowMillis, 0L);
}
@@ -714,14 +727,14 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
ReconnectFactory reconnectFactory,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
- boolean durableAckMode,
+ int durableAckTiers,
long durableAckKeepaliveIntervalMillis,
int maxHeadFrameRejections,
long poisonMinEscalationWindowMillis,
long catchUpCapGapMinEscalationWindowMillis) {
this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
reconnectInitialBackoffMillis,
- reconnectMaxBackoffMillis, durableAckMode,
+ reconnectMaxBackoffMillis, durableAckTiers,
durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis,
CatchUpCapGapPolicy.RETRY_FOREVER);
@@ -737,7 +750,7 @@ private CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
ReconnectFactory reconnectFactory,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
- boolean durableAckMode,
+ int durableAckTiers,
long durableAckKeepaliveIntervalMillis,
int maxHeadFrameRejections,
long poisonMinEscalationWindowMillis,
@@ -898,7 +911,8 @@ private CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
this.reconnectFactory = reconnectFactory;
this.reconnectInitialBackoffMillis = reconnectInitialBackoffMillis;
this.reconnectMaxBackoffMillis = reconnectMaxBackoffMillis;
- this.durableAckMode = durableAckMode;
+ this.durableAckMode = durableAckTiers != DurableAckTiers.NONE;
+ this.isLocalAckTrimming = DurableAckTiers.isTrimOnLocalAck(durableAckTiers);
// Saturate, never multiply raw -- the same hazard the cap-gap dwell above
// guards. A raw multiply wraps a large millisecond value NEGATIVE, and both of
// these read as "elapsed >= window", so a negative makes the gate trivially
@@ -941,7 +955,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
ReconnectFactory reconnectFactory,
long reconnectInitialBackoffMillis,
long reconnectMaxBackoffMillis,
- boolean durableAckMode,
+ int durableAckTiers,
long durableAckKeepaliveIntervalMillis,
int maxHeadFrameRejections,
long poisonMinEscalationWindowMillis,
@@ -949,7 +963,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
ReconnectPolicy reconnectPolicy) {
this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
reconnectInitialBackoffMillis,
- reconnectMaxBackoffMillis, durableAckMode,
+ reconnectMaxBackoffMillis, durableAckTiers,
durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis,
catchUpPolicyFor(reconnectPolicy));
@@ -1477,6 +1491,28 @@ public long getTotalDurableAcks() {
return totalDurableAcks.get();
}
+ /**
+ * Total {@code STATUS_LOCAL_DURABLE_ACK} frames received since the loop
+ * started. Always 0 unless the local tier was requested. In local-only
+ * mode these frames drive the trim; with both tiers requested they are
+ * progress signals and the count grows independently of trims.
+ */
+ public long getTotalLocalDurableAcks() {
+ return totalLocalDurableAcks.get();
+ }
+
+ /**
+ * The highest local-fsync seqTxn reported for the table via
+ * {@code STATUS_LOCAL_DURABLE_ACK}, or -1 when none arrived. Meaningful
+ * when both tiers are requested: the local frontier runs ahead of the
+ * replicated trim, and this exposes that early progress per table. In
+ * local-only mode the local acks feed the trim watermarks directly and
+ * this map stays empty.
+ */
+ public long getLocalDurableTableWatermark(CharSequence tableName) {
+ return localDurableTableWatermarks.get(tableName);
+ }
+
/**
* Total times a durable-ack frame caused {@link CursorSendEngine#acknowledge}
* to advance. Always 0 when {@code durableAckMode} is false. A non-zero
@@ -1680,6 +1716,23 @@ private void applyDurableAck() {
drainPendingDurable();
}
+ /**
+ * Records per-table local-fsync watermarks from a
+ * STATUS_LOCAL_DURABLE_ACK frame when both tiers are requested. Progress
+ * observability only: the trim path is driven by the replicated ack.
+ */
+ private void applyLocalDurableAckProgress() {
+ int n = response.getTableEntryCount();
+ for (int i = 0; i < n; i++) {
+ String name = response.getTableName(i);
+ long seqTxn = response.getTableSeqTxn(i);
+ long current = localDurableTableWatermarks.get(name);
+ if (seqTxn > current) {
+ localDurableTableWatermarks.put(name, seqTxn);
+ }
+ }
+ }
+
/**
* Drives the very first connect attempt on the I/O thread, used in the
* async-initial-connect mode (constructed with {@code client == null}).
@@ -1704,6 +1757,7 @@ private void clearDurableAckTracking() {
releasePendingEntry(pendingDurable.pollFirst());
}
durableTableWatermarks.clear();
+ localDurableTableWatermarks.clear();
// Reset the keepalive throttle so the new connection can prod the
// server immediately rather than waiting out the leftover interval
// from before the reconnect.
@@ -3932,6 +3986,24 @@ public void onBinaryMessage(long payloadPtr, int payloadLen) {
applyDurableAck();
return;
}
+ if (response.isLocalDurableAck()) {
+ if (!durableAckMode) {
+ LOG.warn("received STATUS_LOCAL_DURABLE_ACK frame without opt-in -- ignoring");
+ return;
+ }
+ totalLocalDurableAcks.incrementAndGet();
+ if (isLocalAckTrimming) {
+ // Local is the strongest requested tier, so its ack is
+ // this connection's trim trigger -- same watermark and
+ // drain path the replicated ack drives otherwise.
+ applyDurableAck();
+ } else {
+ // Both tiers requested: the replicated ack trims; the
+ // local ack is an early progress signal only.
+ applyLocalDurableAckProgress();
+ }
+ return;
+ }
// Application-layer rejection by the server. Classify by status
// byte → SenderError.Category, resolve policy (default mapping
// for now; user-override resolution lands in a later commit),
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictRecoveryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictRecoveryTest.java
index 66ba0749..fbc833d8 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictRecoveryTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictRecoveryTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.Sender;
import io.questdb.client.SenderError;
import io.questdb.client.cutlass.line.LineSenderException;
@@ -345,7 +346,7 @@ public void testQueuedOrphanCannotAdoptSlotWhileQuarantineRecreatesItsName() thr
BackgroundDrainer queuedDrainer = new BackgroundDrainer(
staleSnapshotPath, 256, 8192, () -> null,
- 1000, 1, 10, true, 0);
+ 1000, 1, 10, DurableAckTiers.REPLICATED, 0);
Thread drainerThread = new Thread(queuedDrainer, "qwp-queued-orphan");
drainerThread.start();
drainerThread.join(5_000);
@@ -982,7 +983,7 @@ public void testPersistFailureSurfacesAsLineSenderException() throws Exception {
slot, 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff);
Sender sender = QwpWebSocketSender.connect(
- "localhost", port, null, 0, 0, 0L, null, false, engine);
+ "localhost", port, null, 0, 0, 0L, null, DurableAckTiers.NONE, engine);
try {
ff.armed = true; // the next dictionary append cannot grow its window
sender.table("m").symbol("s", "boom").longColumn("v", 1L).atNow();
@@ -1552,7 +1553,7 @@ public void testFullDictFramesRecoverBesideASurvivingPopulatedDictionary() throw
slot.toString(), 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, phase1DictFf);
try (Sender s1 = QwpWebSocketSender.connect(
- "localhost", port, null, 0, 0, 0L, null, false, phase1Engine, 0L)) {
+ "localhost", port, null, 0, 0, 0L, null, DurableAckTiers.NONE, phase1Engine, 0L)) {
for (int i = 0; i < DISTINCT_SYMBOLS; i++) {
s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
s1.flush();
@@ -1639,7 +1640,7 @@ public void testDiscardedSurvivingDictionaryIsUnlinked() throws Exception {
slot.toString(), 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, new UnopenableDictFacade());
try (Sender s1 = QwpWebSocketSender.connect(
- "localhost", port, null, 0, 0, 0L, null, false, phase1Engine, 0L)) {
+ "localhost", port, null, 0, 0, 0L, null, DurableAckTiers.NONE, phase1Engine, 0L)) {
for (int i = 0; i < DISTINCT_SYMBOLS; i++) {
s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
s1.flush();
@@ -1720,7 +1721,7 @@ public void testFullDictFramesRecoverInFullDictModeInsteadOfBricking() throws Ex
slot.toString(), 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, phase1DictFf);
try (Sender s1 = QwpWebSocketSender.connect(
- "localhost", port, null, 0, 0, 0L, null, false, phase1Engine, 0L)) {
+ "localhost", port, null, 0, 0, 0L, null, DurableAckTiers.NONE, phase1Engine, 0L)) {
for (int i = 0; i < DISTINCT_SYMBOLS; i++) {
s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
s1.flush();
@@ -2119,7 +2120,7 @@ public void testPersistFailureDegradesToFullDictInsteadOfKillingFlushForever() t
slot, 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff);
Sender sender = QwpWebSocketSender.connect(
- "localhost", port, null, 0, 0, 0L, null, false, engine);
+ "localhost", port, null, 0, 0, 0L, null, DurableAckTiers.NONE, engine);
try {
// Armed from the start, so the very first ensureAppendMap is refused --
// a later append would sit inside the window already mapped and never
@@ -2354,7 +2355,7 @@ public void testTransientDictFaultRecoversInFullDictModeWhenEveryFrameIsSelfSuff
slot.toString(), 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, new UnopenableDictFacade());
try (Sender s1 = QwpWebSocketSender.connect(
- "localhost", port, null, 0, 0, 0L, null, false, phase1Engine, 0L)) {
+ "localhost", port, null, 0, 0, 0L, null, DurableAckTiers.NONE, phase1Engine, 0L)) {
for (int i = 0; i < DISTINCT_SYMBOLS; i++) {
s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
s1.flush();
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DurableAckTiersTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DurableAckTiersTest.java
new file mode 100644
index 00000000..87b65565
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DurableAckTiersTest.java
@@ -0,0 +1,148 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2026 QuestDB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ ******************************************************************************/
+
+package io.questdb.client.test.cutlass.qwp.client;
+
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
+import org.junit.Test;
+
+import static io.questdb.client.cutlass.qwp.client.DurableAckTiers.LEGACY_TRUE;
+import static io.questdb.client.cutlass.qwp.client.DurableAckTiers.LOCAL;
+import static io.questdb.client.cutlass.qwp.client.DurableAckTiers.NONE;
+import static io.questdb.client.cutlass.qwp.client.DurableAckTiers.REPLICATED;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Unit tests for the {@link DurableAckTiers} bitmask helpers: config-value
+ * parsing and printing, the request/confirmation wire tokens, and the
+ * trim-trigger predicate.
+ */
+public class DurableAckTiersTest {
+
+ @Test
+ public void testConfigValueInverseOfParse() {
+ // configValue . parseConfigValue is identity for every accepted value
+ // (modulo case and the replicated,local ordering alias).
+ String[] canonical = {"off", "on", "local", "replicated", "local,replicated"};
+ for (String v : canonical) {
+ assertEquals(v, DurableAckTiers.configValue(DurableAckTiers.parseConfigValue(v)));
+ }
+ // The reversed alias normalizes to the canonical order.
+ assertEquals("local,replicated",
+ DurableAckTiers.configValue(DurableAckTiers.parseConfigValue("replicated,local")));
+ }
+
+ @Test
+ public void testConfigValuePrintsTierSets() {
+ assertEquals("off", DurableAckTiers.configValue(NONE));
+ assertEquals("local", DurableAckTiers.configValue(LOCAL));
+ assertEquals("replicated", DurableAckTiers.configValue(REPLICATED));
+ assertEquals("local,replicated", DurableAckTiers.configValue(LOCAL | REPLICATED));
+ assertEquals("on", DurableAckTiers.configValue(REPLICATED | LEGACY_TRUE));
+ }
+
+ @Test
+ public void testExpectedConfirmToken() {
+ // Explicit requests are confirmed by their own token echoed back;
+ // the legacy request is confirmed by "enabled"; no request expects
+ // no confirmation.
+ assertNull(DurableAckTiers.expectedConfirmToken(NONE));
+ assertEquals("local", DurableAckTiers.expectedConfirmToken(LOCAL));
+ assertEquals("replicated", DurableAckTiers.expectedConfirmToken(REPLICATED));
+ assertEquals("local,replicated", DurableAckTiers.expectedConfirmToken(LOCAL | REPLICATED));
+ assertEquals("enabled", DurableAckTiers.expectedConfirmToken(REPLICATED | LEGACY_TRUE));
+ }
+
+ @Test
+ public void testHasLocalHasReplicated() {
+ assertFalse(DurableAckTiers.hasLocal(NONE));
+ assertFalse(DurableAckTiers.hasReplicated(NONE));
+ assertTrue(DurableAckTiers.hasLocal(LOCAL));
+ assertFalse(DurableAckTiers.hasReplicated(LOCAL));
+ assertFalse(DurableAckTiers.hasLocal(REPLICATED));
+ assertTrue(DurableAckTiers.hasReplicated(REPLICATED));
+ assertTrue(DurableAckTiers.hasLocal(LOCAL | REPLICATED));
+ assertTrue(DurableAckTiers.hasReplicated(LOCAL | REPLICATED));
+ assertTrue(DurableAckTiers.hasReplicated(REPLICATED | LEGACY_TRUE));
+ }
+
+ @Test
+ public void testIsTrimOnLocalAck() {
+ // Only a local-without-replicated request trims on the local ack;
+ // any set that includes the replicated tier trims on the replicated
+ // ack (strongest requested guarantee wins).
+ assertTrue(DurableAckTiers.isTrimOnLocalAck(LOCAL));
+ assertFalse(DurableAckTiers.isTrimOnLocalAck(NONE));
+ assertFalse(DurableAckTiers.isTrimOnLocalAck(REPLICATED));
+ assertFalse(DurableAckTiers.isTrimOnLocalAck(LOCAL | REPLICATED));
+ assertFalse(DurableAckTiers.isTrimOnLocalAck(REPLICATED | LEGACY_TRUE));
+ }
+
+ @Test
+ public void testParseConfigValueAccepted() {
+ assertEquals(NONE, DurableAckTiers.parseConfigValue("off"));
+ assertEquals(REPLICATED | LEGACY_TRUE, DurableAckTiers.parseConfigValue("on"));
+ assertEquals(LOCAL, DurableAckTiers.parseConfigValue("local"));
+ assertEquals(REPLICATED, DurableAckTiers.parseConfigValue("replicated"));
+ assertEquals(LOCAL | REPLICATED, DurableAckTiers.parseConfigValue("local,replicated"));
+ assertEquals(LOCAL | REPLICATED, DurableAckTiers.parseConfigValue("replicated,local"));
+ }
+
+ @Test
+ public void testParseConfigValueCaseInsensitiveAndTrimmed() {
+ assertEquals(REPLICATED | LEGACY_TRUE, DurableAckTiers.parseConfigValue("ON"));
+ assertEquals(LOCAL, DurableAckTiers.parseConfigValue("Local"));
+ assertEquals(LOCAL | REPLICATED, DurableAckTiers.parseConfigValue("LOCAL,REPLICATED"));
+ assertEquals(NONE, DurableAckTiers.parseConfigValue(" off "));
+ }
+
+ @Test
+ public void testParseConfigValueRejected() {
+ // The whole value must match one of the accepted spellings; spaces
+ // inside a list, empty and unknown tokens, and null all read as -1.
+ assertEquals(-1, DurableAckTiers.parseConfigValue(null));
+ assertEquals(-1, DurableAckTiers.parseConfigValue(""));
+ assertEquals(-1, DurableAckTiers.parseConfigValue("true"));
+ assertEquals(-1, DurableAckTiers.parseConfigValue("yes"));
+ assertEquals(-1, DurableAckTiers.parseConfigValue("enabled"));
+ assertEquals(-1, DurableAckTiers.parseConfigValue("local, replicated"));
+ assertEquals(-1, DurableAckTiers.parseConfigValue("local,"));
+ assertEquals(-1, DurableAckTiers.parseConfigValue("local,local"));
+ assertEquals(-1, DurableAckTiers.parseConfigValue("remote"));
+ }
+
+ @Test
+ public void testRequestHeaderValue() {
+ // The legacy set is sent as the literal "true"; explicit sets send
+ // their token list; no request means no header.
+ assertNull(DurableAckTiers.requestHeaderValue(NONE));
+ assertEquals("local", DurableAckTiers.requestHeaderValue(LOCAL));
+ assertEquals("replicated", DurableAckTiers.requestHeaderValue(REPLICATED));
+ assertEquals("local,replicated", DurableAckTiers.requestHeaderValue(LOCAL | REPLICATED));
+ assertEquals("true", DurableAckTiers.requestHeaderValue(REPLICATED | LEGACY_TRUE));
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/MmapFaultDegradesTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/MmapFaultDegradesTest.java
index 360cbf5e..6e0bf773 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/MmapFaultDegradesTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/MmapFaultDegradesTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.Sender;
import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
@@ -101,7 +102,7 @@ public void testMmapAccessFaultDegradesPersistInsteadOfPropagating() throws Exce
slot, 4L * 1024 * 1024, 64L * 1024 * 1024,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff);
QwpWebSocketSender sender = QwpWebSocketSender.connect(
- "localhost", port, null, 0, 0, 0L, null, false, engine);
+ "localhost", port, null, 0, 0, 0L, null, DurableAckTiers.NONE, engine);
try {
ff.armed = true; // the next dictionary mmap growth raises the fault
sender.table("m").symbol("s", "boom").longColumn("v", 1L).atNow();
@@ -190,7 +191,7 @@ public void testMmapAccessFaultDuringDictHealDegradesInsteadOfEscapingBuild() th
// now so ONLY the heal's later MAP_RW growth of that same fd raises the fault.
ff.armed = true;
QwpWebSocketSender resumed = QwpWebSocketSender.connect(
- "localhost", port, null, 0, 0, 0L, null, false, engine);
+ "localhost", port, null, 0, 0, 0L, null, DurableAckTiers.NONE, engine);
try {
Assert.assertFalse("a recognised mmap access fault during the dictionary heal "
+ "must degrade the sender to self-sufficient frames, not escape build()",
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderJvmErrorCleanupTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderJvmErrorCleanupTest.java
index d0eb80db..23367930 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderJvmErrorCleanupTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderJvmErrorCleanupTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.cutlass.qwp.client.QwpHostHealthTracker;
@@ -184,7 +185,7 @@ public void testErrorAtSuccessTailEntryClosesConnectedClient() throws Exception
// tail -- narrowing the try block later trips one of the two.
QwpWebSocketSender sender = newBareSender();
QwpHostHealthTracker tracker = wireEndpoints(sender, 1);
- setField(sender, "requestDurableAck", true);
+ setField(sender, "durableAckTiers", DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE);
OutOfMemoryError oom = new OutOfMemoryError("simulated allocation failure");
StubClient stub = newStubClient();
stub.durableAckCheckError = oom;
@@ -364,7 +365,7 @@ public void setQwpMaxVersion(int maxVersion) {
}
@Override
- public void setQwpRequestDurableAck(boolean enabled) {
+ public void setQwpDurableAckTiers(int tiers) {
}
@Override
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java
index 3911f5c0..d90fd478 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.cutlass.line.array.DoubleArray;
import io.questdb.client.cutlass.line.array.LongArray;
@@ -349,7 +350,7 @@ public void testFlushAppendFailureDoesNotLeaveMicrobatchBufferInUse() throws Exc
CursorSendEngine engine = new CursorSendEngine(null, minSegmentBytes, minSegmentBytes, 1L);
try (QwpWebSocketSender sender = QwpWebSocketSender.connect(
"localhost", port, null, Integer.MAX_VALUE, 0, 0L, null,
- false, engine, 0L)) {
+ DurableAckTiers.NONE, engine, 0L)) {
sender.table("t").longColumn("v", 1L).atNow();
try {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SelfSufficientFramesTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SelfSufficientFramesTest.java
index e305210b..1d1aaebf 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SelfSufficientFramesTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SelfSufficientFramesTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.Sender;
import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
@@ -159,7 +160,7 @@ public void testDiskModeFallsBackToFullDictWhenPersistedDictUnopenable() throws
slot, 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, dictFf);
try (Sender sender = QwpWebSocketSender.connect(
- "localhost", port, null, 0, 0, 0L, null, false, engine)) {
+ "localhost", port, null, 0, 0, 0L, null, DurableAckTiers.NONE, engine)) {
sender.table("foo").symbol("s", "alpha").longColumn("v", 1L).atNow();
sender.flush();
waitFor(() -> handler.batches.size() >= 1, 5_000);
@@ -751,7 +752,7 @@ public void testDictionaryLargerThanTheCapShipsAsChunkedDictionaryFrames() throw
// 40 x ~60-byte entries is ~2.4 KB of dictionary against a 512-byte cap,
// so no single frame can carry it and at least five chunks are required.
try (Sender sender = QwpWebSocketSender.connect(
- "localhost", port, null, 1_000_000, 0, 0L, null, false, engine)) {
+ "localhost", port, null, 1_000_000, 0, 0L, null, DurableAckTiers.NONE, engine)) {
String pad = TestUtils.repeat("x", 55);
for (int i = 0; i < symbols; i++) {
String sym = String.format("%04d", i) + pad;
@@ -826,7 +827,7 @@ public void testFullDictNearCapFallsBackToChunkedDictionary() throws Exception {
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, dictFf);
List expected = new ArrayList<>();
try (Sender sender = QwpWebSocketSender.connect(
- "localhost", port, null, 1_000_000, 0, 0L, null, false, engine)) {
+ "localhost", port, null, 1_000_000, 0, 0L, null, DurableAckTiers.NONE, engine)) {
// 10 x 48-char symbols: dict section 10 x 49 = 490 bytes, dict-only
// frame 504 -- one row under the 512 cap.
String pad = TestUtils.repeat("s", 46);
@@ -900,7 +901,7 @@ public void testFullDictNearCapFallbackSplitsMultiTableBatch() throws Exception
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, dictFf);
List expected = new ArrayList<>();
try (Sender sender = QwpWebSocketSender.connect(
- "localhost", port, null, 1_000_000, 0, 0L, null, false, engine)) {
+ "localhost", port, null, 1_000_000, 0, 0L, null, DurableAckTiers.NONE, engine)) {
String symPad = TestUtils.repeat("s", 46);
// t1 registers the 10 symbols (ids 0..9): small body, whole dict.
for (int i = 0; i < 10; i++) {
@@ -973,7 +974,7 @@ public void testFullDictNearCapOversizedBodyStrandsNoChunks() throws Exception {
slot, 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, dictFf);
try (Sender sender = QwpWebSocketSender.connect(
- "localhost", port, null, 1_000_000, 0, 0L, null, false, engine)) {
+ "localhost", port, null, 1_000_000, 0, 0L, null, DurableAckTiers.NONE, engine)) {
String symPad = TestUtils.repeat("s", 46);
String rowPad = TestUtils.repeat("x", 40);
// 20 rows cycling through 10 symbols: the same 504-byte dictionary
@@ -1028,7 +1029,7 @@ public void testSingleSymbolLargerThanTheCapThrowsWithNothingPublished() throws
slot, 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, dictFf);
try (Sender sender = QwpWebSocketSender.connect(
- "localhost", port, null, 1_000_000, 0, 0L, null, false, engine)) {
+ "localhost", port, null, 1_000_000, 0, 0L, null, DurableAckTiers.NONE, engine)) {
// Small enough to pass sendRow's per-row guard, too large for a
// dictionary frame of its own once the header and varints are added.
sender.table("t").symbol("s", TestUtils.repeat("y", 250))
@@ -1209,7 +1210,7 @@ public void testSectionOverCapWithAnOversizedBodyPublishesNothingOnEveryRetry()
slot, 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, dictFf);
Sender sender = QwpWebSocketSender.connect(
- "localhost", port, null, 1_000_000, 0, 0L, null, false, engine);
+ "localhost", port, null, 1_000_000, 0, 0L, null, DurableAckTiers.NONE, engine);
try {
String symPad = TestUtils.repeat("s", 46);
String rowPad = TestUtils.repeat("x", 60);
@@ -1287,7 +1288,7 @@ public void testDictionaryChunksAreCommittedWhenTheDataFrameFailsToPublish() thr
slot, 100L, 4L * 1024 * 1024,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, dictFf);
Sender sender = QwpWebSocketSender.connect(
- "localhost", port, null, 1_000_000, 0, 0L, null, false, engine);
+ "localhost", port, null, 1_000_000, 0, 0L, null, DurableAckTiers.NONE, engine);
try {
// One 40-char symbol makes the dictionary section big enough that
// section+body busts the 150-byte cap, while the body alone fits it
@@ -1364,7 +1365,7 @@ public void testFullDictCommitFrameCarriesNoDictionaryAfterACancelledRow() throw
slot, 4L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, dictFf);
try (Sender sender = QwpWebSocketSender.connect(
- "localhost", port, null, 1_000_000, 0, 0L, null, false, engine)) {
+ "localhost", port, null, 1_000_000, 0, 0L, null, DurableAckTiers.NONE, engine)) {
QwpWebSocketSender ws = (QwpWebSocketSender) sender;
Assert.assertFalse("an unopenable .symbol-dict must select full-dict mode",
ws.isDeltaDictEnabledForTest());
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java
index 2ae605b7..afcdf4f1 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.Sender;
import io.questdb.client.SenderConnectionEvent;
@@ -412,7 +413,7 @@ public void testDelegatedIoThreadEngineCloseFlipsSlotLockReleased() throws Excep
null /* async-initial-connect: the I/O thread drives the connect */,
engine, 0L, 1_000L,
stuckConnect,
- 100L, 5_000L, false);
+ 100L, 5_000L, DurableAckTiers.NONE);
loop.start();
Assert.assertTrue("I/O thread never reached the connect factory",
enteredConnect.await(5, TimeUnit.SECONDS));
@@ -543,7 +544,7 @@ public void testFailedIoStopReclaimsSenderResourcesAfterWorkerExit() throws Exce
};
loop = new CursorWebSocketSendLoop(
null, engine, 0L, 1_000L, stuckConnect,
- 100L, 5_000L, false);
+ 100L, 5_000L, DurableAckTiers.NONE);
loop.start();
Assert.assertTrue("I/O thread never reached the connect factory",
enteredConnect.await(5, TimeUnit.SECONDS));
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketResponseLocalDurableAckTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketResponseLocalDurableAckTest.java
new file mode 100644
index 00000000..419a8239
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketResponseLocalDurableAckTest.java
@@ -0,0 +1,109 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2026 QuestDB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ ******************************************************************************/
+
+package io.questdb.client.test.cutlass.qwp.client;
+
+import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Wire-format tests for the {@code STATUS_LOCAL_DURABLE_ACK} response: it
+ * shares the sequence-less {@code status + tableCount + entries} layout with
+ * {@code STATUS_DURABLE_ACK} and must survive a write/read round trip,
+ * validate structurally, and classify via {@code isLocalDurableAck()} only.
+ */
+public class WebSocketResponseLocalDurableAckTest {
+
+ @Test
+ public void testClassificationIsMutuallyExclusive() {
+ WebSocketResponse local = WebSocketResponse.localDurableAck("trades", 7L);
+ assertTrue(local.isLocalDurableAck());
+ assertFalse(local.isDurableAck());
+ assertFalse(local.isSuccess());
+ assertEquals("LOCAL_DURABLE_ACK", local.getStatusName());
+
+ WebSocketResponse replicated = WebSocketResponse.durableAck("trades", 7L);
+ assertTrue(replicated.isDurableAck());
+ assertFalse(replicated.isLocalDurableAck());
+ assertEquals("DURABLE_ACK", replicated.getStatusName());
+ }
+
+ @Test
+ public void testStructurallyValid() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ WebSocketResponse response = WebSocketResponse.localDurableAck("trades", 42L);
+ int size = response.serializedSize();
+ long ptr = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
+ try {
+ assertEquals(size, response.writeTo(ptr));
+ assertTrue(WebSocketResponse.isStructurallyValid(ptr, size));
+ // A truncated frame (entry cut short) must not validate.
+ assertFalse(WebSocketResponse.isStructurallyValid(ptr, size - 1));
+ } finally {
+ Unsafe.free(ptr, size, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ @Test
+ public void testToStringNamesLocalStatus() {
+ WebSocketResponse response = WebSocketResponse.localDurableAck("trades", 1L);
+ assertEquals("WebSocketResponse{status=LOCAL_DURABLE_ACK, tables=1}", response.toString());
+ }
+
+ @Test
+ public void testWriteReadRoundTrip() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ WebSocketResponse out = WebSocketResponse.localDurableAck("trades", 42L);
+ int size = out.serializedSize();
+ long ptr = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
+ try {
+ assertEquals(size, out.writeTo(ptr));
+
+ WebSocketResponse in = new WebSocketResponse();
+ assertTrue(in.readFrom(ptr, size));
+ assertEquals(WebSocketResponse.STATUS_LOCAL_DURABLE_ACK, in.getStatus());
+ assertTrue(in.isLocalDurableAck());
+ assertFalse("local ack must not classify as the replicated ack",
+ in.isDurableAck());
+ assertEquals(-1L, in.getSequence());
+ assertEquals(1, in.getTableEntryCount());
+ assertEquals("trades", in.getTableName(0));
+ assertEquals(42L, in.getTableSeqTxn(0));
+
+ // Truncated input must be rejected, not misparsed.
+ assertFalse(new WebSocketResponse().readFrom(ptr, size - 1));
+ } finally {
+ Unsafe.free(ptr, size, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/DurableAckIntegrationTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/DurableAckIntegrationTest.java
index 3378f717..a50e5ffc 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/DurableAckIntegrationTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/DurableAckIntegrationTest.java
@@ -27,6 +27,10 @@
import io.questdb.client.Sender;
import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
+import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
+import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
import io.questdb.client.std.Files;
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
import io.questdb.client.test.tools.TestUtils;
@@ -125,6 +129,224 @@ public void testConnectStringOnRequiresServerSupport() throws Exception {
});
}
+ @Test
+ public void testBooleanConnectOverloadRequestsLegacyTier() throws Exception {
+ // The boolean connect overloads map true onto the legacy request:
+ // the upgrade header carries "true" and the "enabled" grant is
+ // accepted. Callers linked against the boolean signatures keep the
+ // pre-tier wire behavior. (ExportedApiCompatibilityTest pins the
+ // signatures; this pins what they do.)
+ TestUtils.assertMemoryLeak(() -> {
+ Assert.assertEquals(0, Files.mkdir(sfDir, Files.DIR_MODE_DEFAULT));
+ DurableAckCapableHandler handler = new DurableAckCapableHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler, true)) {
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+ int port = server.getPort();
+
+ CursorSendEngine engine = new CursorSendEngine(sfDir, 16384);
+ try (Sender ignored = QwpWebSocketSender.connect(
+ "localhost", port, null, 0, 0, 0L, null, true, engine)) {
+ Assert.assertEquals("true",
+ server.pollDurableAckRequest(5, TimeUnit.SECONDS));
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testBuilderInvalidTierValueRejected() {
+ // The programmatic CharSequence overload applies the same parse as the
+ // config string: a typo must throw, naming the key and the value.
+ try {
+ Sender.builder(Sender.Transport.WEBSOCKET).requestDurableAck("yes");
+ Assert.fail("expected LineSenderException for invalid tier value");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(
+ "message names the offending key+value, was: " + e.getMessage(),
+ e.getMessage().contains("request_durable_ack")
+ && e.getMessage().contains("yes"));
+ }
+ }
+
+ @Test
+ public void testBuilderRejectsTiersOnHttpTransport() {
+ // Durable-ack streams exist only on the WebSocket transport; asking
+ // for a tier on an HTTP builder must fail fast at configuration time.
+ try {
+ Sender.builder(Sender.Transport.HTTP).requestDurableAck("local");
+ Assert.fail("expected LineSenderException for HTTP transport");
+ } catch (LineSenderException e) {
+ Assert.assertTrue("was: " + e.getMessage(),
+ e.getMessage().contains("only supported for WebSocket"));
+ }
+ }
+
+ @Test
+ public void testConnectStringLocalRequiresServerSupport() throws Exception {
+ // Tier requests keep the all-or-nothing contract of the legacy opt-in:
+ // a server that does not confirm the durable-ack grant (no
+ // X-QWP-Durable-Ack header) must fail the connect, not leave the
+ // store-and-forward log growing while waiting on acks that never come.
+ TestUtils.assertMemoryLeak(() -> {
+ DurableAckCapableHandler handler = new DurableAckCapableHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler, false)) {
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ int port = server.getPort();
+ String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";request_durable_ack=local;";
+ try (Sender ignored = Sender.fromConfig(config)) {
+ Assert.fail("expected connect to fail with QwpDurableAckMismatchException");
+ } catch (QwpDurableAckMismatchException e) {
+ Assert.assertEquals("localhost", e.getHost());
+ Assert.assertEquals(port, e.getPort());
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testLocalRequestDeniedWhenServerGrantsDifferentSet() throws Exception {
+ // A server that answers a "local" request with the legacy "enabled"
+ // token granted a set other than the one requested. The client must
+ // read any token except its expected one as a denial -- trimming on a
+ // foreign grant could otherwise drop data on a guarantee weaker than
+ // the caller configured.
+ TestUtils.assertMemoryLeak(() -> {
+ DurableAckCapableHandler handler = new DurableAckCapableHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler, true)) {
+ server.setDurableAckHeaderValue("enabled");
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ int port = server.getPort();
+ String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";request_durable_ack=local;";
+ try (Sender ignored = Sender.fromConfig(config)) {
+ Assert.fail("expected connect to fail with QwpDurableAckMismatchException");
+ } catch (QwpDurableAckMismatchException e) {
+ Assert.assertEquals(port, e.getPort());
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testRequestHeaderCarriesConfiguredTierSet() throws Exception {
+ // The upgrade request must carry the exact token for each configured
+ // set: the legacy "on" travels as "true" (the request value
+ // tier-unaware servers recognize), explicit sets travel verbatim.
+ // No rows are sent, so close() returns without waiting on acks.
+ TestUtils.assertMemoryLeak(() -> {
+ DurableAckCapableHandler handler = new DurableAckCapableHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler, true)) {
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+ int port = server.getPort();
+
+ String[][] cases = {
+ {"on", "true"},
+ {"local", "local"},
+ {"replicated", "replicated"},
+ {"local,replicated", "local,replicated"},
+ };
+ for (String[] c : cases) {
+ String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir
+ + ";request_durable_ack=" + c[0] + ";";
+ Sender.fromConfig(config).close();
+ Assert.assertEquals("config value " + c[0],
+ c[1], server.pollDurableAckRequest(5, TimeUnit.SECONDS));
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testEndToEndBothTiersLocalAckIsProgressOnly() throws Exception {
+ // request_durable_ack=local,replicated: the replicated ack is the trim
+ // trigger; local acks surface early per-table progress without popping
+ // anything. The loop's counters and the per-table local watermark are
+ // the observable surface for that split.
+ TestUtils.assertMemoryLeak(() -> {
+ DurableAckCapableHandler handler = new DurableAckCapableHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler, true)) {
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ int port = server.getPort();
+ String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir
+ + ";request_durable_ack=local,replicated;close_flush_timeout_millis=5000;";
+ try (Sender sender = Sender.fromConfig(config)) {
+ for (int i = 0; i < 10; i++) {
+ sender.table("trades").longColumn("v", i).atNow();
+ }
+ sender.flush(); // one batch -> one OK
+ handler.awaitOkBatches(1);
+ long batches = 1;
+
+ CursorWebSocketSendLoop loop =
+ ((QwpWebSocketSender) sender).cursorSendLoopForTest();
+ Assert.assertNotNull(loop);
+
+ // Release local acks covering everything OK'd, then nudge
+ // the connection with extra rows until the I/O thread has
+ // observed one -- the local ack alone must not trim.
+ handler.emitLocalDurableAckForAll();
+ long deadline = System.currentTimeMillis() + 5000;
+ while (loop.getTotalLocalDurableAcks() == 0
+ && System.currentTimeMillis() < deadline) {
+ sender.table("trades").longColumn("v", -1L).atNow();
+ sender.flush();
+ batches++;
+ Thread.sleep(10);
+ }
+ Assert.assertTrue("local ack never observed",
+ loop.getTotalLocalDurableAcks() > 0);
+ Assert.assertTrue("local watermark tracks the fsync frontier",
+ loop.getLocalDurableTableWatermark("trades") >= 0);
+ Assert.assertEquals(
+ "local acks must not advance trim when replicated is requested",
+ 0L, loop.getTotalDurableTrimAdvances());
+
+ // Cover every batch sent (nudges included) with a
+ // replicated ack so close() drains on the actual trim
+ // trigger.
+ handler.awaitOkBatches(batches);
+ handler.emitDurableAckForAll();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testEndToEndLocalTrimDefersUntilLocalAck() throws Exception {
+ // request_durable_ack=local: OK frames alone never trim; the
+ // STATUS_LOCAL_DURABLE_ACK stream is the trim trigger. close() drains
+ // only once the local ack covers everything sent -- the local-tier
+ // mirror of testEndToEndDurableTrimDefersUntilUploadAck.
+ TestUtils.assertMemoryLeak(() -> {
+ DurableAckCapableHandler handler = new DurableAckCapableHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler, true)) {
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ int port = server.getPort();
+ String config = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir
+ + ";request_durable_ack=local;close_flush_timeout_millis=5000;";
+ try (Sender sender = Sender.fromConfig(config)) {
+ for (int i = 0; i < 50; i++) {
+ sender.table("trades").longColumn("v", i).atNow();
+ }
+ sender.flush(); // one batch -> one OK
+ handler.awaitOkBatches(1);
+ handler.emitLocalDurableAckForAll();
+ }
+ // close() returned without timing out: the local ack drove the
+ // trim to completion.
+ }
+ });
+ }
+
@Test
public void testEndToEndDurableTrimDefersUntilUploadAck() throws Exception {
// Server confirms support and emits OK acks but no durable-acks at first.
@@ -147,12 +369,12 @@ public void testEndToEndDurableTrimDefersUntilUploadAck() throws Exception {
}
sender.flush();
- // Wait for the server to OK every batch so we know the OK
- // watermark is fully advanced. Without a durable-ack the
- // client's ackedFsn must still be behind publishedFsn --
+ // Wait for the server to OK the flushed batch so we know
+ // the OK watermark is fully advanced. Without a durable-ack
+ // the client's ackedFsn must still be behind publishedFsn --
// we don't assert on internals here, just observe that
// the contract holds at the boundary check below.
- handler.awaitOks();
+ handler.awaitOkBatches(1);
// Release a cumulative durable-ack covering everything that
// has been OK'd so far. The client's I/O thread reads new
@@ -169,10 +391,12 @@ public void testEndToEndDurableTrimDefersUntilUploadAck() throws Exception {
});
}
- private static byte[] buildDurableAckFrame(long seqTxn) {
+ private static byte[] buildDurableAckFrame(byte status, long seqTxn) {
+ // STATUS_DURABLE_ACK and STATUS_LOCAL_DURABLE_ACK share the layout:
+ // status(1) + tableCount(2) + nameLen(2) + name + seqTxn(8).
byte[] name = DurableAckCapableHandler.TABLE_NAME.getBytes(StandardCharsets.UTF_8);
ByteBuffer bb = ByteBuffer.allocate(1 + 2 + 2 + name.length + 8).order(ByteOrder.LITTLE_ENDIAN);
- bb.put((byte) 0x02); // STATUS_DURABLE_ACK
+ bb.put(status);
bb.putShort((short) 1); // tableCount
bb.putShort((short) name.length);
bb.put(name);
@@ -230,20 +454,36 @@ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] dat
}
}
- void awaitOks() throws InterruptedException {
+ void awaitOkBatches(long count) throws InterruptedException {
+ // One OK frame per QWP batch (i.e. per flush), NOT per row. A
+ // silent return on timeout would let the caller proceed on a
+ // watermark that never advanced, so this fails loudly instead.
long deadline = System.currentTimeMillis() + (long) 5000;
- while (totalOks() < (long) 50 && System.currentTimeMillis() < deadline) {
+ while (totalOks() < count && System.currentTimeMillis() < deadline) {
Thread.sleep(10);
}
+ Assert.assertTrue(
+ "server never OK'd " + count + " batch(es), got " + totalOks(),
+ totalOks() >= count);
}
void emitDurableAckForAll() throws IOException {
// Cumulative durable-ack: every OK already issued is now durable.
// Single-table handler so one entry suffices.
+ emitAckForAll(WebSocketResponse.STATUS_DURABLE_ACK);
+ }
+
+ void emitLocalDurableAckForAll() throws IOException {
+ // Cumulative local-durability ack: every OK already issued is now
+ // fdatasync-durable on the "server".
+ emitAckForAll(WebSocketResponse.STATUS_LOCAL_DURABLE_ACK);
+ }
+
+ private void emitAckForAll(byte status) throws IOException {
TestWebSocketServer.ClientHandler c = activeClient;
if (c != null) {
long seqTxn = Math.max(0L, nextSeqTxn.get() - 1L);
- c.sendBinary(buildDurableAckFrame(seqTxn));
+ c.sendBinary(buildDurableAckFrame(status, seqTxn));
}
}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerCredentialOutageReportTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerCredentialOutageReportTest.java
index 7cbffe82..3019e1f2 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerCredentialOutageReportTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerCredentialOutageReportTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.SenderError;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketClientFactory;
@@ -270,7 +271,7 @@ private BackgroundDrainer newDrainer(ScriptedWireFactory factory) {
RECONNECT_MAX_DURATION_MILLIS,
FAST_BACKOFF_MILLIS,
FAST_BACKOFF_MAX_MILLIS,
- /* requestDurableAck */ true,
+ /* durableAckTiers */ DurableAckTiers.REPLICATED,
/* durableAckKeepaliveIntervalMillis */ 200L);
}
@@ -404,7 +405,7 @@ public WebSocketClient reconnect() throws Exception {
WebSocketClient c = WebSocketClientFactory.newPlainTextInstance();
try {
c.setQwpMaxVersion(1);
- c.setQwpRequestDurableAck(true);
+ c.setQwpDurableAckTiers(DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE);
c.setConnectTimeout(5_000);
c.connect("localhost", port);
c.upgrade("/write/v4", 5_000, null);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerDurableAckRetryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerDurableAckRetryTest.java
index fb6bbfb2..6b094789 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerDurableAckRetryTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerDurableAckRetryTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.SenderError;
import io.questdb.client.cutlass.http.client.WebSocketClient;
@@ -1540,7 +1541,7 @@ private BackgroundDrainer newDrainerWithBudgets(
reconnectMaxDurationMillis,
backoffInitMillis,
backoffMaxMillis,
- /* requestDurableAck */ true,
+ /* durableAckTiers */ DurableAckTiers.REPLICATED,
/* durableAckKeepaliveIntervalMillis */ 200L);
}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerInterruptIsStopSignalTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerInterruptIsStopSignalTest.java
index a73aef26..578142f9 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerInterruptIsStopSignalTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerInterruptIsStopSignalTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
import io.questdb.client.std.Files;
@@ -120,7 +121,7 @@ public void bareInterruptStopsConnectPhaseDrainer() throws Exception {
firstAttempt.countDown();
throw new IOException("connection refused (test)");
},
- 5_000L, 1L, 10L, false, 0L);
+ 5_000L, 1L, 10L, DurableAckTiers.NONE, 0L);
Thread runner = new Thread(drainer::run, "drainer-runner");
runner.setDaemon(true);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerInterruptedTeardownTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerInterruptedTeardownTest.java
index d1526146..22e7e898 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerInterruptedTeardownTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerInterruptedTeardownTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
@@ -171,7 +172,7 @@ public void testC5_interruptedTeardownMustNotReleaseSlotUnderLiveIoThread() thro
final BackgroundDrainer drainer = new BackgroundDrainer(
tmpDir, SEGMENT_BYTES, Long.MAX_VALUE, factory,
- 5_000L, 10L, 50L, false, 0L);
+ 5_000L, 10L, 50L, DurableAckTiers.NONE, 0L);
Thread runner = new Thread(drainer::run, "drainer-runner");
runner.setDaemon(true);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainAuthRejectTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainAuthRejectTest.java
index db5efa91..e8c221cf 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainAuthRejectTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainAuthRejectTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.SenderError;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketClientFactory;
@@ -317,7 +318,7 @@ private BackgroundDrainer newDrainer(ScriptedWireFactory factory, long reconnect
reconnectMaxDurationMillis,
FAST_BACKOFF_MILLIS,
FAST_BACKOFF_MAX_MILLIS,
- /* requestDurableAck */ true,
+ /* durableAckTiers */ DurableAckTiers.REPLICATED,
/* durableAckKeepaliveIntervalMillis */ 200L);
}
@@ -559,7 +560,7 @@ public WebSocketClient reconnect() throws Exception {
WebSocketClient c = WebSocketClientFactory.newPlainTextInstance();
try {
c.setQwpMaxVersion(1);
- c.setQwpRequestDurableAck(true);
+ c.setQwpDurableAckTiers(DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE);
c.setConnectTimeout(5_000);
c.connect("localhost", port);
c.upgrade("/write/v4", 5_000, null);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainCapabilityGapTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainCapabilityGapTest.java
index 6f2224a1..4881ce23 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainCapabilityGapTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerMidDrainCapabilityGapTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketClientFactory;
import io.questdb.client.cutlass.http.client.WebSocketUpgradeException;
@@ -393,7 +394,7 @@ private BackgroundDrainer newDrainer(ScriptedWireFactory factory) {
RECONNECT_MAX_DURATION_MILLIS,
FAST_BACKOFF_MILLIS,
FAST_BACKOFF_MAX_MILLIS,
- /* requestDurableAck */ true,
+ /* durableAckTiers */ DurableAckTiers.REPLICATED,
/* durableAckKeepaliveIntervalMillis */ 200L);
}
@@ -645,7 +646,7 @@ public WebSocketClient reconnect() throws Exception {
WebSocketClient c = WebSocketClientFactory.newPlainTextInstance();
try {
c.setQwpMaxVersion(1);
- c.setQwpRequestDurableAck(true);
+ c.setQwpDurableAckTiers(DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE);
c.setConnectTimeout(5_000);
c.connect("localhost", port);
c.upgrade("/write/v4", 5_000, null);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerOrphanTailTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerOrphanTailTest.java
index 99f59817..895c489e 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerOrphanTailTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerOrphanTailTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
@@ -74,7 +75,7 @@ public void testDeferredOnlyRecoveredTailRetiresWithoutConnecting() throws Excep
5_000L,
1L,
5L,
- true,
+ DurableAckTiers.REPLICATED,
200L);
drainer.run();
@@ -132,7 +133,7 @@ public void testPreAdoptionSetupFailureDoesNotQuarantineTheSlot() throws Excepti
5_000L,
1L,
5L,
- true,
+ DurableAckTiers.REPLICATED,
200L);
drainer.run();
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerPoolConnectPhaseCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerPoolConnectPhaseCloseTest.java
index d31f60d4..3f2a5e12 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerPoolConnectPhaseCloseTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerPoolConnectPhaseCloseTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool;
@@ -115,7 +116,7 @@ public void testCloseStopsConnectPhaseDrainerWithoutBurningGracefulWindow() thro
/* reconnectMaxDurationMillis */ 60_000L,
/* reconnectInitialBackoffMillis */ LONG_BACKOFF_MILLIS,
/* reconnectMaxBackoffMillis */ LONG_BACKOFF_MILLIS,
- /* requestDurableAck */ false,
+ /* durableAckTiers */ DurableAckTiers.NONE,
/* durableAckKeepaliveIntervalMillis */ 0L);
final BackgroundDrainerPool pool = new BackgroundDrainerPool(1);
pool.submit(drainer);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerPoolInterruptedCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerPoolInterruptedCloseTest.java
index d8a3e82e..c6cecd02 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerPoolInterruptedCloseTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerPoolInterruptedCloseTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
@@ -130,7 +131,7 @@ public void interruptedCloseMustStopActivelyDrainingDrainers() throws Exception
firstAttempt.countDown();
throw new IOException("connection refused (test)");
},
- 5_000L, 1L, 10L, false, 0L);
+ 5_000L, 1L, 10L, DurableAckTiers.NONE, 0L);
BackgroundDrainerPool pool = new BackgroundDrainerPool(1);
try {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerSetupFailureTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerSetupFailureTest.java
index a8bd96c9..ceaf80b2 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerSetupFailureTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerSetupFailureTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWatermark;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
@@ -75,7 +76,7 @@ public void testConnectErrorPropagatesWithoutQuarantine() throws Exception {
5_000L,
1L,
10L,
- true,
+ DurableAckTiers.REPLICATED,
200L);
LinkageError thrown = null;
@@ -123,7 +124,7 @@ public void testConstructionErrorPropagatesWithoutQuarantine() throws Exception
5_000L,
1L,
10L,
- true,
+ DurableAckTiers.REPLICATED,
200L);
OutOfMemoryError thrown = null;
@@ -182,7 +183,7 @@ public void testCorruptRecoveredChainIsQuarantined() throws Exception {
5_000L,
1L,
10L,
- true,
+ DurableAckTiers.REPLICATED,
200L);
drainer.run();
@@ -268,7 +269,7 @@ public void testSealedResidueFirstSightHealsAndDoesNotQuarantine() throws Except
5_000L,
1L,
10L,
- true,
+ DurableAckTiers.REPLICATED,
200L);
LinkageError thrown = null;
@@ -330,7 +331,7 @@ public void testLockOpenFailureDoesNotQuarantineRecoverableData() throws Excepti
5_000L,
1L,
10L,
- true,
+ DurableAckTiers.REPLICATED,
200L);
drainer.run();
@@ -370,7 +371,7 @@ public void testWatermarkOpenFailureDoesNotQuarantineRecoverableData() throws Ex
5_000L,
1L,
10L,
- true,
+ DurableAckTiers.REPLICATED,
200L);
drainer.run();
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerTransportOutageRecoveryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerTransportOutageRecoveryTest.java
index 86a3decc..f3278321 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerTransportOutageRecoveryTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerTransportOutageRecoveryTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketClientFactory;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
@@ -108,7 +109,7 @@ public void testDrainerSurvivesOutageLongerThanBudgetThenDrainsWhenServerReturns
RECONNECT_MAX_DURATION_MILLIS,
FAST_BACKOFF_MILLIS,
FAST_BACKOFF_MAX_MILLIS,
- /* requestDurableAck */ true,
+ /* durableAckTiers */ DurableAckTiers.REPLICATED,
/* durableAckKeepaliveIntervalMillis */ 200L);
CountingListener listener = new CountingListener();
drainer.setListener(listener);
@@ -305,7 +306,7 @@ public WebSocketClient reconnect() throws Exception {
WebSocketClient c = WebSocketClientFactory.newPlainTextInstance();
try {
c.setQwpMaxVersion(1);
- c.setQwpRequestDurableAck(true);
+ c.setQwpDurableAckTiers(DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE);
c.setConnectTimeout(5_000);
c.connect("localhost", port);
c.upgrade("/write/v4", 5_000, null);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerUnreplayableSlotQuarantineTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerUnreplayableSlotQuarantineTest.java
index a67a805d..c7a56af8 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerUnreplayableSlotQuarantineTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerUnreplayableSlotQuarantineTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.SenderError;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
@@ -92,7 +93,7 @@ public void testSecondDrainerNeverReAdoptsAQuarantinedSlot() throws Exception {
throw new AssertionError(
"recovery failure must be caught before any connect attempt");
},
- 5_000L, 1L, 5L, true, 200L);
+ 5_000L, 1L, 5L, DurableAckTiers.REPLICATED, 200L);
List captured = Collections.synchronizedList(new ArrayList());
drainer1.setErrorSink(captured::add);
@@ -135,7 +136,7 @@ public void testSecondDrainerNeverReAdoptsAQuarantinedSlot() throws Exception {
connectAttempts.incrementAndGet();
throw new AssertionError("a quarantined slot must never be re-adopted");
},
- 5_000L, 1L, 5L, true, 200L);
+ 5_000L, 1L, 5L, DurableAckTiers.REPLICATED, 200L);
drainer2.run();
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CloseOwnershipRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CloseOwnershipRaceTest.java
index fc1b9257..40dcd793 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CloseOwnershipRaceTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CloseOwnershipRaceTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.qwp.client.QwpAuthFailedException;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
@@ -71,7 +72,7 @@ public void closeOwnershipSnapshotNeverClaimsAnUnsurfacedError() {
throw new QwpAuthFailedException(401, "localhost", 1);
},
1, 1,
- false,
+ DurableAckTiers.NONE,
0,
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
0,
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopBlockedSendCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopBlockedSendCloseTest.java
index 38578586..f6a3973b 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopBlockedSendCloseTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopBlockedSendCloseTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.line.LineSenderException;
@@ -57,7 +58,7 @@ public void testCloseBreaksBlockedSendBeforeJoiningWorker() throws Exception {
null,
1_000L,
5_000L,
- false
+ DurableAckTiers.NONE
);
long payload = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
Thread closer = null;
@@ -133,7 +134,7 @@ public void testUnsupportedCustomTransportFailsWithoutDestroyingWorkerResources(
null,
1_000L,
5_000L,
- false
+ DurableAckTiers.NONE
);
long payload = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
Thread closer = null;
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java
index ec828aae..54eafea3 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.line.LineSenderException;
@@ -1173,7 +1174,7 @@ private void assertUnrelatedReconnectStateRestartsCapGapEpisode(boolean roleReje
}
throw new AssertionError("unexpected reconnect call " + call);
},
- 0L, 0L, false,
+ 0L, 0L, DurableAckTiers.NONE,
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
0L, TimeUnit.HOURS.toMillis(1),
@@ -1327,7 +1328,7 @@ private CursorWebSocketSendLoop newLoop(
() -> {
throw new UnsupportedOperationException("test loop is never started");
},
- 100L, 5_000L, false,
+ 100L, 5_000L, DurableAckTiers.NONE,
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
0L, capGapWindowMillis,
@@ -1344,7 +1345,7 @@ private CursorWebSocketSendLoop newForegroundLoop(
() -> {
throw new UnsupportedOperationException("test loop is never started");
},
- 100L, 5_000L, false,
+ 100L, 5_000L, DurableAckTiers.NONE,
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
0L, 0L);
@@ -1433,7 +1434,7 @@ private void assertConnectLoopEntry(boolean reenterWithCapGap) throws Exception
observedAnchor[0] = loopRef[0].catchUpCapGapFirstNanos();
return new CatchUpCapturingClient(0);
},
- 100L, 5_000L, false,
+ 100L, 5_000L, DurableAckTiers.NONE,
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
0L, 0L,
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectPhaseCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectPhaseCloseTest.java
index 77e6028d..b863b561 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectPhaseCloseTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectPhaseCloseTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketFrameHandler;
@@ -154,7 +155,7 @@ public WebSocketClient reconnect(CursorWebSocketSendLoop.ConnectCancellation can
factory,
/* reconnectInitialBackoffMillis */ 1_000L,
/* reconnectMaxBackoffMillis */ 5_000L,
- false
+ DurableAckTiers.NONE
);
// Shrink the bounded-await backstop so the timeout branch fires fast
// (no multi-second real wait); production uses the 30s default.
@@ -272,7 +273,7 @@ public WebSocketClient reconnect(CursorWebSocketSendLoop.ConnectCancellation can
factory,
/* reconnectInitialBackoffMillis */ 1_000L,
/* reconnectMaxBackoffMillis */ 5_000L,
- false
+ DurableAckTiers.NONE
);
final AtomicReference closeFailure = new AtomicReference<>();
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckFuzzTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckFuzzTest.java
index 11c6388e..329d3f31 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckFuzzTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckFuzzTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.LineSenderServerException;
import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
@@ -197,7 +198,7 @@ private static void runOneIteration(Rnd rnd, int iter) throws Exception {
() -> {
throw new UnsupportedOperationException();
},
- 100L, 5_000L, true);
+ 100L, 5_000L, DurableAckTiers.REPLICATED);
Field f = CursorWebSocketSendLoop.class.getDeclaredField("nextWireSeq");
f.setAccessible(true);
f.setLong(loop, frames);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckTest.java
index 80cb68b5..dcf4eb40 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.LineSenderServerException;
import io.questdb.client.SenderError;
import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
@@ -291,6 +292,146 @@ public void testDurableModePartialCoverageDoesNotAdvance() throws Exception {
});
}
+ @Test
+ public void testBothTiersLocalAckIsProgressOnly() throws Exception {
+ // With local,replicated requested the trim trigger is the replicated
+ // ack. A local ack must not pop the pending queue or move ackedFsn;
+ // it only bumps the local counter and the per-table local watermark.
+ // The replicated ack then trims as usual.
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrames(engine, 1);
+ CursorWebSocketSendLoop loop = newBothTiersLoop(engine);
+ setSentCount(loop, 1);
+ deliverOk(loop, 0, names("trades"), txns(7L));
+
+ deliverLocalDurableAck(loop, names("trades"), txns(7L));
+ assertEquals("local ack must not trim when replicated is requested",
+ -1L, engine.ackedFsn());
+ assertEquals(1, pendingSize(loop));
+ assertEquals(1L, loop.getTotalLocalDurableAcks());
+ assertEquals(0L, loop.getTotalDurableTrimAdvances());
+ assertEquals("local watermark records the fsync frontier",
+ 7L, loop.getLocalDurableTableWatermark("trades"));
+
+ deliverDurableAck(loop, names("trades"), txns(7L));
+ assertEquals(0L, engine.ackedFsn());
+ assertEquals(0, pendingSize(loop));
+ assertEquals(1L, loop.getTotalDurableAcks());
+ assertEquals(1L, loop.getTotalDurableTrimAdvances());
+ }
+ });
+ }
+
+ @Test
+ public void testBothTiersLocalWatermarkIsMonotonic() throws Exception {
+ // A delayed/duplicate local ack naming a smaller seqTxn must not move
+ // the per-table local watermark backwards; the frame still counts.
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = newEngine()) {
+ CursorWebSocketSendLoop loop = newBothTiersLoop(engine);
+
+ deliverLocalDurableAck(loop, names("trades"), txns(10L));
+ assertEquals(10L, loop.getLocalDurableTableWatermark("trades"));
+
+ deliverLocalDurableAck(loop, names("trades"), txns(5L));
+ assertEquals("stale local ack must not unwind the watermark",
+ 10L, loop.getLocalDurableTableWatermark("trades"));
+ assertEquals(2L, loop.getTotalLocalDurableAcks());
+
+ assertEquals("unseen table reads as -1",
+ -1L, loop.getLocalDurableTableWatermark("orders"));
+ }
+ });
+ }
+
+ @Test
+ public void testDefaultModeIgnoresStrayLocalDurableAck() throws Exception {
+ // Without any opt-in a STATUS_LOCAL_DURABLE_ACK frame is logged and
+ // dropped: no trim, no counter movement.
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrames(engine, 1);
+ CursorWebSocketSendLoop loop = newDefaultLoop(engine);
+ setSentCount(loop, 1);
+ deliverLocalDurableAck(loop, names("anything"), txns(99L));
+ assertEquals(-1L, engine.ackedFsn());
+ assertEquals(0L, loop.getTotalLocalDurableAcks());
+ }
+ });
+ }
+
+ @Test
+ public void testLocalOnlyModeLocalAckAdvancesTrim() throws Exception {
+ // With only the local tier requested, STATUS_LOCAL_DURABLE_ACK is the
+ // trim trigger: an OK queues the entry and the local ack drains it
+ // through the same watermark path the replicated ack drives otherwise.
+ // The progress-only watermark map stays untouched in this mode.
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrames(engine, 1);
+ CursorWebSocketSendLoop loop = newLocalLoop(engine);
+ setSentCount(loop, 1);
+
+ deliverOk(loop, 0, names("trades"), txns(7L));
+ assertEquals("OK alone must not trim in local-only mode",
+ -1L, engine.ackedFsn());
+ assertEquals(1, pendingSize(loop));
+
+ deliverLocalDurableAck(loop, names("trades"), txns(7L));
+ assertEquals(0L, engine.ackedFsn());
+ assertEquals(0, pendingSize(loop));
+ assertEquals(1L, loop.getTotalLocalDurableAcks());
+ assertEquals(0L, loop.getTotalDurableAcks());
+ assertEquals(1L, loop.getTotalDurableTrimAdvances());
+ assertEquals("local-only mode feeds the trim watermarks, not the progress map",
+ -1L, loop.getLocalDurableTableWatermark("trades"));
+ }
+ });
+ }
+
+ @Test
+ public void testLocalOnlyModeReplicatedAckAlsoTrims() throws Exception {
+ // The server grants all-or-nothing, so a local-only connection should
+ // never see STATUS_DURABLE_ACK -- but if one arrives, trimming on it
+ // is safe: object-store durability subsumes the local-fsync guarantee
+ // the caller asked for.
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = newEngine()) {
+ appendFrames(engine, 1);
+ CursorWebSocketSendLoop loop = newLocalLoop(engine);
+ setSentCount(loop, 1);
+ deliverOk(loop, 0, names("trades"), txns(7L));
+
+ deliverDurableAck(loop, names("trades"), txns(7L));
+ assertEquals(0L, engine.ackedFsn());
+ assertEquals(1L, loop.getTotalDurableAcks());
+ assertEquals(0L, loop.getTotalLocalDurableAcks());
+ }
+ });
+ }
+
+ @Test
+ public void testReconnectClearsLocalWatermarks() throws Exception {
+ // clearDurableAckTracking (invoked on every client swap) must drop the
+ // local progress watermarks along with the trim state: the new
+ // connection's server rebuilds both frontiers from scratch.
+ TestUtils.assertMemoryLeak(() -> {
+ try (CursorSendEngine engine = newEngine()) {
+ CursorWebSocketSendLoop loop = newBothTiersLoop(engine);
+ deliverLocalDurableAck(loop, names("trades"), txns(42L));
+ assertEquals(42L, loop.getLocalDurableTableWatermark("trades"));
+
+ Method m = CursorWebSocketSendLoop.class.getDeclaredMethod("clearDurableAckTracking");
+ m.setAccessible(true);
+ m.invoke(loop);
+
+ assertEquals("stale local watermarks must not survive a reconnect",
+ -1L, loop.getLocalDurableTableWatermark("trades"));
+ }
+ });
+ }
+
@Test
public void testNackInDurableModeIsTerminalAndDoesNotAdvanceTrim() throws Exception {
// A SCHEMA_MISMATCH NACK is TERMINAL: it latches the typed error and
@@ -542,13 +683,14 @@ private static void appendFrames(CursorSendEngine engine, int count) {
}
}
- private static long buildDurableAckPayload(String[] tableNames, long[] seqTxns) {
- // STATUS_DURABLE_ACK frame: status(1) + tableCount(2) + entries(nameLen(2)+name+seqTxn(8))
+ private static long buildDurableAckPayload(byte status, String[] tableNames, long[] seqTxns) {
+ // Durable-ack frame (STATUS_DURABLE_ACK or STATUS_LOCAL_DURABLE_ACK,
+ // same layout): status(1) + tableCount(2) + entries(nameLen(2)+name+seqTxn(8))
int size = 3;
for (String t : tableNames) size += 2 + t.getBytes(StandardCharsets.UTF_8).length + 8;
long ptr = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
int offset = 0;
- Unsafe.getUnsafe().putByte(ptr + offset, WebSocketResponse.STATUS_DURABLE_ACK);
+ Unsafe.getUnsafe().putByte(ptr + offset, status);
offset += 1;
Unsafe.getUnsafe().putShort(ptr + offset, (short) tableNames.length);
offset += 2;
@@ -609,7 +751,15 @@ private static long buildOkPayload(long wireSeq, String[] tableNames, long[] seq
}
private static void deliverDurableAck(CursorWebSocketSendLoop loop, String[] tableNames, long[] seqTxns) throws Exception {
- long packed = buildDurableAckPayload(tableNames, seqTxns);
+ deliverAck(loop, WebSocketResponse.STATUS_DURABLE_ACK, tableNames, seqTxns);
+ }
+
+ private static void deliverLocalDurableAck(CursorWebSocketSendLoop loop, String[] tableNames, long[] seqTxns) throws Exception {
+ deliverAck(loop, WebSocketResponse.STATUS_LOCAL_DURABLE_ACK, tableNames, seqTxns);
+ }
+
+ private static void deliverAck(CursorWebSocketSendLoop loop, byte status, String[] tableNames, long[] seqTxns) throws Exception {
+ long packed = buildDurableAckPayload(status, tableNames, seqTxns);
long ptr = packed & 0xFFFFFFFFFFFFL;
int size = (int) (packed >>> 48);
try {
@@ -663,21 +813,28 @@ private CursorSendEngine newEngine() {
}
private CursorWebSocketSendLoop newDefaultLoop(CursorSendEngine engine) {
- return new CursorWebSocketSendLoop(
- null, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
- () -> {
- throw new UnsupportedOperationException("test loop is never started");
- },
- 100L, 5_000L, false);
+ return newLoop(engine, DurableAckTiers.NONE);
}
private CursorWebSocketSendLoop newDurableLoop(CursorSendEngine engine) {
+ return newLoop(engine, DurableAckTiers.REPLICATED);
+ }
+
+ private CursorWebSocketSendLoop newLocalLoop(CursorSendEngine engine) {
+ return newLoop(engine, DurableAckTiers.LOCAL);
+ }
+
+ private CursorWebSocketSendLoop newBothTiersLoop(CursorSendEngine engine) {
+ return newLoop(engine, DurableAckTiers.LOCAL | DurableAckTiers.REPLICATED);
+ }
+
+ private CursorWebSocketSendLoop newLoop(CursorSendEngine engine, int durableAckTiers) {
return new CursorWebSocketSendLoop(
null, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
() -> {
throw new UnsupportedOperationException("test loop is never started");
},
- 100L, 5_000L, true);
+ 100L, 5_000L, durableAckTiers);
}
private static int pendingSize(CursorWebSocketSendLoop loop) throws Exception {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopForegroundReconnectPolicyTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopForegroundReconnectPolicyTest.java
index 902df846..bc3a47e0 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopForegroundReconnectPolicyTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopForegroundReconnectPolicyTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketClientFactory;
@@ -95,7 +96,7 @@ public void testFirstConnectCatchUpFailureKeepsStartupTerminalArmed() throws Exc
factory,
1L,
4L,
- false,
+ DurableAckTiers.NONE,
0L,
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
0L,
@@ -165,7 +166,7 @@ private void assertAsyncInitialForegroundSurfacesTerminal(
factory,
1L,
4L,
- durableAck,
+ durableAck ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE : DurableAckTiers.NONE,
durableAck ? 10L : 0L,
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
0L,
@@ -222,7 +223,7 @@ private void assertForegroundRecovers(boolean durableAck, FailureSupplier failur
factory,
1L,
4L,
- durableAck,
+ durableAck ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE : DurableAckTiers.NONE,
durableAck ? 10L : 0L,
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
0L,
@@ -296,7 +297,9 @@ private static WebSocketClient connect(int port, boolean durableAck) throws Exce
WebSocketClient client = WebSocketClientFactory.newPlainTextInstance();
try {
client.setQwpMaxVersion(1);
- client.setQwpRequestDurableAck(durableAck);
+ client.setQwpDurableAckTiers(durableAck
+ ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE
+ : DurableAckTiers.NONE);
client.setConnectTimeout(5_000);
client.connect("localhost", port);
client.upgrade("/write/v4", 5_000, null);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopInterruptedCloseLeakTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopInterruptedCloseLeakTest.java
index ad17a1ca..9e49144a 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopInterruptedCloseLeakTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopInterruptedCloseLeakTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
@@ -107,7 +108,7 @@ public void testC5_interruptedCloseMustNotLeakClientInstalledByInFlightReconnect
null /* async-initial-connect: the I/O thread drives the connect */,
engine, 0L, 1_000L,
stuckConnect,
- 100L, 5_000L, false);
+ 100L, 5_000L, DurableAckTiers.NONE);
loop.start();
Assert.assertTrue("I/O thread never reached the reconnect factory",
enteredReconnect.await(5, TimeUnit.SECONDS));
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java
index b215e476..cc2b4f58 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.Sender;
import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
@@ -340,7 +341,7 @@ private static CursorWebSocketSendLoop newRecoveryLoop(CursorSendEngine engine)
throw new IOException("no reconnect in this test");
},
0, 1,
- false, 0L, 3, 0L, 0L,
+ DurableAckTiers.NONE, 0L, 3, 0L, 0L,
CursorWebSocketSendLoop.ReconnectPolicy.ORPHAN);
}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java
index 1f446a11..25b12813 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.LineSenderServerException;
import io.questdb.client.SenderError;
@@ -348,7 +349,7 @@ public void testNackRecycleIsPacedAgainstHealthyServer() throws Exception {
CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
factory,
initialBackoffMillis, 1_000L,
- false,
+ DurableAckTiers.NONE,
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
// Keep the detector out of the way: this test measures
// pacing, not escalation.
@@ -415,7 +416,7 @@ public void testNonOrderlyCloseRecycleIsPacedAgainstAcceptingMiddlebox() throws
CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
factory,
initialBackoffMillis, 1_000L,
- false,
+ DurableAckTiers.NONE,
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
// Keep the detector out of the way: this test measures
// close-path pacing, not escalation -- escalation is
@@ -482,7 +483,7 @@ public void testOrderlyCloseChurnIsPacedAfterFirstRecycle() throws Exception {
CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
factory,
initialBackoffMillis, 1_000L,
- false,
+ DurableAckTiers.NONE,
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
// Detector out of the way -- orderly closes must not
// strike anyway; this test measures pacing.
@@ -552,7 +553,7 @@ public void testPreSendCloseChurnIsPaced() throws Exception {
CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
factory,
initialBackoffMillis, 1_000L,
- false,
+ DurableAckTiers.NONE,
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
1_000_000);
try {
@@ -1212,7 +1213,7 @@ private CursorWebSocketSendLoop newDurableLoop(CursorSendEngine engine,
clients.add(c);
return c;
},
- 5L, 10L, true,
+ 5L, 10L, DurableAckTiers.REPLICATED,
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
MAX_REJECTIONS);
// The loop is driven directly by the test, not by its own I/O thread,
@@ -1239,7 +1240,7 @@ private CursorWebSocketSendLoop newExemptPacerLoop(CursorSendEngine engine,
clients.add(c);
return c;
},
- initialBackoffMillis, 5 * initialBackoffMillis, true,
+ initialBackoffMillis, 5 * initialBackoffMillis, DurableAckTiers.REPLICATED,
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
1_000_000);
loop.setRunningForTest(true);
@@ -1263,7 +1264,7 @@ private CursorWebSocketSendLoop newDurableLoopWithWindow(CursorSendEngine engine
clients.add(c);
return c;
},
- 5L, 10L, true,
+ 5L, 10L, DurableAckTiers.REPLICATED,
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
maxRejections, windowMillis);
loop.setRunningForTest(true);
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java
index afe6baaa..c0b896a3 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketFrameHandler;
@@ -92,7 +93,7 @@ public void tailFrameSurvivesSegmentRotation() throws Exception {
() -> {
throw new UnsupportedOperationException("no reconnect in this test");
},
- 100L, 5_000L, false);
+ 100L, 5_000L, DurableAckTiers.NONE);
long buf = Unsafe.malloc(PAYLOAD_LEN, MemoryTag.NATIVE_DEFAULT);
try {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java
index 0c78e777..b215185e 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
@@ -200,7 +201,7 @@ public void testConnectRollbackDoesNotReclaimTheLogicalLock() throws Exception {
// A fresh slot: publishedFsn() < 0, so the rollback close takes the fully-drained arm.
CursorSendEngine engine = new CursorSendEngine(slotDir, 4L * 1024 * 1024);
try {
- QwpWebSocketSender.connect("localhost", refusedPort, null, 0, 0, 0L, null, false, engine);
+ QwpWebSocketSender.connect("localhost", refusedPort, null, 0, 0, 0L, null, DurableAckTiers.NONE, engine);
fail("connect to a refused port must fail and roll back");
} catch (LineSenderException expected) {
// the connect()-rollback path ran and closed the engine
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/OrphanScannerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/OrphanScannerTest.java
index 226cc4b9..09dbda34 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/OrphanScannerTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/OrphanScannerTest.java
@@ -24,6 +24,7 @@
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
import io.questdb.client.std.Files;
@@ -363,7 +364,7 @@ public void testDrainerRevalidatesStaleScannerSnapshotBeforeCreatingEngine() thr
BackgroundDrainer drainer = new BackgroundDrainer(
slot, 1024, 8192, () -> {
throw new AssertionError("a stale candidate must not connect");
- }, 1000, 1, 10, true, 0);
+ }, 1000, 1, 10, DurableAckTiers.REPLICATED, 0);
drainer.run();
assertEquals(BackgroundDrainer.DrainOutcome.SUCCESS, drainer.outcome());
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java
index 908e3bd5..8e6e35b9 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java
@@ -60,6 +60,10 @@ public class TestWebSocketServer implements Closeable {
// Authorization header value captured from each well-formed upgrade request ("" when absent), in
// arrival order. Tests poll this to assert the token a provider supplied at each (re)handshake.
private final BlockingQueue capturedAuthHeaders = new LinkedBlockingQueue<>();
+ // X-QWP-Request-Durable-Ack header value captured from each well-formed upgrade request
+ // ("" when absent), in arrival order. Tests poll this to assert the exact request token
+ // the client sent for a configured tier set ("true", "local", "local,replicated", ...).
+ private final BlockingQueue capturedDurableAckRequests = new LinkedBlockingQueue<>();
private final List clients = new CopyOnWriteArrayList<>();
private final boolean emitDurableAckHeader;
private final WebSocketServerHandler handler;
@@ -115,6 +119,11 @@ public class TestWebSocketServer implements Closeable {
// Live-updatable via setSuppressDurableAckHeader(), so a test can start
// in the gap and later let the cluster "settle".
private volatile boolean suppressDurableAckHeader;
+ // When non-null, 101 upgrade responses carry this exact X-QWP-Durable-Ack value
+ // instead of the granted-set echo -- simulating a server that grants a set other
+ // than the one requested (e.g. "enabled" to a client asking for "local"). The
+ // client must treat any token other than its expected one as a denial.
+ private volatile String durableAckHeaderValue;
// When > 0, the next handshake responds with this status code + the
// reason phrase from {@link #rejectingStatusReason}. Used to simulate
// 401, 403, 404, 426, 503, etc. that the failover loop should
@@ -321,6 +330,25 @@ public void setRejectWithStatus(int statusCode, String reasonPhrase) {
* advertising, the way a rolling upgrade eventually settles. The setting
* applies to every new handshake until cleared.
*/
+ /**
+ * Blocks up to {@code timeout} for the next captured X-QWP-Request-Durable-Ack
+ * header value ("" when the upgrade request carried none). Values arrive in
+ * handshake order, one per well-formed upgrade request.
+ */
+ public String pollDurableAckRequest(long timeout, TimeUnit unit) throws InterruptedException {
+ return capturedDurableAckRequests.poll(timeout, unit);
+ }
+
+ /**
+ * Forces 101 upgrade responses to carry this exact {@code X-QWP-Durable-Ack}
+ * value instead of echoing the client's requested set. Pass null to restore
+ * the echo behavior. Only takes effect on a server constructed with
+ * {@code emitDurableAckHeader}.
+ */
+ public void setDurableAckHeaderValue(String value) {
+ this.durableAckHeaderValue = value;
+ }
+
public void setSuppressDurableAckHeader(boolean suppressDurableAckHeader) {
this.suppressDurableAckHeader = suppressDurableAckHeader;
}
@@ -583,6 +611,7 @@ private boolean performHandshake() throws IOException {
String key = null;
String authorization = "";
+ String durableAckRequest = "";
String[] lines = request.toString().split("\r\n");
if (lines.length > 0) {
// GET HTTP/1.1
@@ -597,6 +626,8 @@ private boolean performHandshake() throws IOException {
key = line.substring(18).trim();
} else if (lower.startsWith("authorization:")) {
authorization = line.substring("authorization:".length()).trim();
+ } else if (lower.startsWith("x-qwp-request-durable-ack:")) {
+ durableAckRequest = line.substring("x-qwp-request-durable-ack:".length()).trim();
}
}
@@ -604,6 +635,7 @@ private boolean performHandshake() throws IOException {
return false;
}
capturedAuthHeaders.add(authorization);
+ capturedDurableAckRequests.add(durableAckRequest);
// Read-path reject: drop the egress upgrade before the 101 so the
// query pool's connect fails fast, while ingest write-path upgrades
@@ -655,7 +687,18 @@ private boolean performHandshake() throws IOException {
.append("Connection: Upgrade\r\n")
.append("Sec-WebSocket-Accept: ").append(acceptKey).append("\r\n");
if (emitDurableAckHeader && !suppressDurableAckHeader) {
- sb.append("X-QWP-Durable-Ack: enabled\r\n");
+ String granted = durableAckHeaderValue;
+ if (granted == null) {
+ // Grant exactly what the client asked for: the legacy "true"
+ // request is confirmed with the "enabled" token, an explicit
+ // tier list is echoed verbatim. A client that did not ask
+ // still sees "enabled" -- it ignores the header without the
+ // opt-in.
+ granted = durableAckRequest.isEmpty() || "true".equalsIgnoreCase(durableAckRequest)
+ ? "enabled"
+ : durableAckRequest;
+ }
+ sb.append("X-QWP-Durable-Ack: ").append(granted).append("\r\n");
}
String role = advertisedRole;
if (role != null) {
diff --git a/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java b/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java
index 3c257827..7a39558c 100644
--- a/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java
+++ b/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java
@@ -58,7 +58,10 @@ public void testEveryIngressKeyIsHonored() {
assertHonored("auto_flush=off", "auto_flush_interval", Integer.MAX_VALUE);
assertHonored("max_name_len=99", "max_name_len", 99);
assertHonored("transaction=on", "transaction", true);
- assertHonored("request_durable_ack=on", "request_durable_ack", true);
+ assertHonored("request_durable_ack=on", "request_durable_ack", "on");
+ assertHonored("request_durable_ack=local", "request_durable_ack", "local");
+ assertHonored("request_durable_ack=replicated", "request_durable_ack", "replicated");
+ assertHonored("request_durable_ack=local,replicated", "request_durable_ack", "local,replicated");
assertHonored("sender_id=probe-1", "sender_id", "probe-1");
assertHonored("sf_dir=/var/probe", "sf_dir", "/var/probe");
assertHonored("sf_max_segment_bytes=4096", "sf_max_segment_bytes", 4096L);