diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 95488d53..93f21abe 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -36,6 +36,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; @@ -1227,7 +1228,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; @@ -2693,9 +2694,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. * @@ -2703,10 +2706,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}. + *

+ * 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; } @@ -3695,7 +3736,7 @@ private QwpWebSocketSender buildWebSocket(HttpTokenProvider tokenProvider) { actualAutoFlushBytes, actualAutoFlushIntervalNanos, wsAuthHeader, - requestDurableAck, + durableAckTiers, cursorEngine, actualCloseFlushTimeoutMillis, actualReconnectMaxDurationMillis, @@ -4162,13 +4203,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"); @@ -4636,13 +4675,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) { @@ -4772,7 +4809,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); @@ -4892,7 +4929,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 c5488956..5eed72fa 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:"; @@ -136,18 +136,25 @@ public abstract class WebSocketClient implements QuietCloseable { // down to its own hard limit. 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; + // Requested durable-ack tier set (DurableAckTiers bitmask), sent as + // X-QWP-Request-Durable-Ack: + private int qwpDurableAckTiers = DurableAckTiers.NONE; // Receive buffer (native memory) private long recvBufPtr; private int recvBufSize; private int recvPos; // Write position private int recvReadPos; // Read position + // Raw X-QWP-Durable-Ack value from the 101 upgrade response, or null when + // the server sent no such header. Reported in the durable-ack mismatch + // error so an operator sees what the server answered. + private String serverDurableAckEcho; // Set during upgrade response validation when the server echoed - // X-QWP-Durable-Ack: enabled. Tells the sender it landed on a server that - // will actually emit STATUS_DURABLE_ACK frames, so its store-and-forward - // path can rely on durable-ack-driven trim. Absence (after opting in via - // setQwpRequestDurableAck) is the early-fail signal. + // X-QWP-Durable-Ack with the token DurableAckTiers.expectedConfirmToken + // expects for the requested set (the set itself, or "enabled" for a + // legacy request). Tells the sender it landed on a server that will emit + // the requested ack frames, so its store-and-forward path can rely on + // durable-ack-driven trim. Absence (after requesting tiers via + // setQwpDurableAckTiers) is the early-fail signal. private boolean serverDurableAckEnabled; // Server's hard cap on ingest QWP message payload bytes, extracted from // X-QWP-Max-Batch-Size on the 101 upgrade response. 0 when the server did @@ -334,6 +341,15 @@ public WebSocketSendBuffer getSendBuffer() { return sendBuffer; } + /** + * Raw {@code X-QWP-Durable-Ack} value from the most recent 101 upgrade + * response, trimmed, or null when the server sent no such header. + * Meaningful only after {@link #upgrade} returns. + */ + public String getServerDurableAckEcho() { + return serverDurableAckEcho; + } + /** * Server-advertised hard cap on QWP ingest payload bytes, taken from the * {@code X-QWP-Max-Batch-Size} response header on the 101 upgrade. Returns @@ -414,10 +430,13 @@ public boolean isConnected() { } /** - * Returns true when the server echoed X-QWP-Durable-Ack: enabled in the - * 101 upgrade response. Meaningful only after {@link #upgrade} returns; - * always false when the client did not opt in via - * {@link #setQwpRequestDurableAck}. + * Returns true when the server granted the requested durable-ack tier set: + * the 101 upgrade response carried {@code X-QWP-Durable-Ack} with the + * token {@link DurableAckTiers#expectedConfirmToken} expects (the + * requested set, such as {@code local} or {@code local,replicated}, or + * {@code enabled} for a legacy request). Meaningful only after + * {@link #upgrade} returns; always false when no tier was requested via + * {@link #setQwpDurableAckTiers}. */ public boolean isServerDurableAckEnabled() { return serverDurableAckEnabled; @@ -599,13 +618,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; } /** @@ -708,8 +728,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: "); @@ -812,7 +834,7 @@ private static int extractContentEncodingZstdLevel(String response) { return 0; } - private static boolean extractDurableAckEnabled(String response) { + private static String extractDurableAckEcho(String response) { int headerLen = QWP_DURABLE_ACK_HEADER_NAME.length(); int responseLen = response.length(); for (int i = 0; i <= responseLen - headerLen; i++) { @@ -822,11 +844,10 @@ private static boolean extractDurableAckEnabled(String response) { if (lineEnd < 0) { lineEnd = responseLen; } - String value = response.substring(valueStart, lineEnd).trim(); - return value.equalsIgnoreCase(QWP_DURABLE_ACK_ENABLED_VALUE); + return response.substring(valueStart, lineEnd).trim(); } } - return false; + return null; } private static int extractMaxBatchSize(String response) { @@ -1430,12 +1451,18 @@ private void validateUpgradeResponse(int headerEnd) { throw new QwpVersionMismatchException(serverQwpVersion, qwpMaxVersion); } - // Extract X-QWP-Durable-Ack confirmation (optional, absent on servers - // without primary replication or when the client did not opt in). - // Only meaningful when qwpRequestDurableAck is true; the sender - // checks this value to fail at connect rather than silently - // missing trim signals. - serverDurableAckEnabled = extractDurableAckEnabled(response); + // Extract X-QWP-Durable-Ack confirmation (optional, absent when the + // server cannot grant the requested tier set or the client requested + // none). Only meaningful when qwpDurableAckTiers is not NONE; the + // sender checks this value to fail at connect rather than silently + // missing trim signals. 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. + serverDurableAckEcho = extractDurableAckEcho(response); + String expectedDurableAckToken = DurableAckTiers.expectedConfirmToken(qwpDurableAckTiers); + serverDurableAckEnabled = expectedDurableAckToken != null + && expectedDurableAckToken.equalsIgnoreCase(serverDurableAckEcho); // 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 #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/QwpDurableAckMismatchException.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpDurableAckMismatchException.java index b20ae250..e8a58e5d 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpDurableAckMismatchException.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpDurableAckMismatchException.java @@ -25,27 +25,49 @@ package io.questdb.client.cutlass.qwp.client; import io.questdb.client.cutlass.http.client.HttpClientException; +import org.jetbrains.annotations.Nullable; /** - * The client opted in via {@code request_durable_ack=on} but the server cannot - * honour it. Terminal: retrying the same configured endpoints will not turn a - * non-primary node into a durable-ack-capable primary, so the connect path - * fails fast instead of burning the full reconnect budget. + * The client requested durable-ack tiers via {@code request_durable_ack} + * ({@code local}, {@code replicated}, {@code local,replicated}, or the legacy + * {@code on}) but the server did not grant that exact set. Terminal: retrying + * the same configured endpoints will not turn a server that cannot grant the + * set into one that can, so the connect path fails fast instead of burning the + * full reconnect budget. *

- * Two flavours collapse into this exception: - *

+ * The server grants all of the requested tiers or none of them: it confirms a + * grant by echoing the requested token set in {@code X-QWP-Durable-Ack} + * ({@code enabled} for a legacy {@code on} request). A missing header, or an + * echo other than the expected token, is a denial. The message names the + * requested set and what the server echoed, or that it sent no header. + * {@link #getRole()} is {@code null} for this denial. */ public final class QwpDurableAckMismatchException extends HttpClientException { private final String host; private final int port; private final String role; + /** + * The upgrade completed but the server did not grant the requested tier + * set. {@code serverEcho} is the server's {@code X-QWP-Durable-Ack} header + * value, or {@code null} when the server sent no such header. + */ + public QwpDurableAckMismatchException(String host, int port, int requestedTiers, @Nullable String serverEcho) { + super("WebSocket upgrade failed: server does not support durable ack [host="); + put(host).put(", port=").put(port) + .put(", requested=").put(DurableAckTiers.configValue(requestedTiers)) + .put(", echoed="); + if (serverEcho != null) { + put(serverEcho); + } else { + put(""); + } + put(']'); + this.host = host; + this.port = port; + this.role = null; + } + public QwpDurableAckMismatchException(String host, int port, String role) { super("WebSocket upgrade failed: server does not support durable ack [host="); put(host).put(", port=").put(port); @@ -67,9 +89,9 @@ public int getPort() { } /** - * Server role from the rejecting endpoint when the failure stemmed from a - * cluster of role-rejecting nodes; {@code null} when the failure was that a - * single primary completed the upgrade without advertising durable ack. + * Server role passed to {@link #QwpDurableAckMismatchException(String, int, String)}; + * {@code null} when the failure was that the server completed the upgrade + * without granting the requested tier set. */ public String getRole() { return role; 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 4da8464c..1da3ab02 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 @@ -631,7 +631,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. @@ -746,7 +746,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 { @@ -771,12 +771,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); } /** @@ -793,13 +793,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, @@ -820,7 +820,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -829,7 +829,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); @@ -850,7 +850,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -860,7 +860,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); @@ -879,7 +879,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -891,7 +891,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, @@ -912,7 +912,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -927,7 +927,7 @@ public static QwpWebSocketSender connect( singleEndpoint(host, port), tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, authorizationHeader, - requestDurableAck, cursorEngine, + durableAckTiers, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, errorHandler, errorInboxCapacity, @@ -950,7 +950,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -964,7 +964,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, @@ -990,7 +990,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -1007,7 +1007,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, @@ -1033,7 +1033,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier( int autoFlushBytes, long autoFlushIntervalNanos, Supplier authorizationHeaderSupplier, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -1049,7 +1049,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, @@ -1089,7 +1089,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -1109,7 +1109,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, @@ -1123,6 +1123,366 @@ autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), CursorWebSocketSendLoop.DEFAULT_KEEPALIVE_INTERVAL_MILLIS); } + // ------------------------------------------------------------------ + // 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 + ); + } + + /** * As the twenty-three-arg {@code connect} overload above, but also accepts the symbol-dictionary * recycle knobs ({@code symbol_dict_reset}, {@code symbol_dict_reset_threshold}, @@ -1172,8 +1532,8 @@ public static QwpWebSocketSender connect( ) { return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), - requestDurableAck, cursorEngine, - closeFlushTimeoutMillis, reconnectMaxDurationMillis, + requestDurableAck ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE : DurableAckTiers.NONE, + cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, errorHandler, errorInboxCapacity, durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs, @@ -1205,7 +1565,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier( int autoFlushBytes, long autoFlushIntervalNanos, Supplier authorizationHeaderSupplier, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -1224,7 +1584,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier( long catchUpCapGapMinEscalationWindowMillis ) { return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, - autoFlushIntervalNanos, authorizationHeaderSupplier, requestDurableAck, + autoFlushIntervalNanos, authorizationHeaderSupplier, durableAckTiers, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, errorHandler, errorInboxCapacity, @@ -1238,6 +1598,118 @@ public static QwpWebSocketSender connectWithCredentialSupplier( CursorWebSocketSendLoop.DEFAULT_KEEPALIVE_INTERVAL_MILLIS); } + public static QwpWebSocketSender connectWithCredentialSupplier( + List endpoints, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + Supplier authorizationHeaderSupplier, + 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 connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, + autoFlushIntervalNanos, authorizationHeaderSupplier, + requestDurableAck ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE : DurableAckTiers.NONE, + cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, + reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, + initialConnectMode, errorHandler, errorInboxCapacity, + durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs, + connectionListener, connectionListenerInboxCapacity); + } + + public static QwpWebSocketSender connectWithCredentialSupplier( + List endpoints, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + Supplier authorizationHeaderSupplier, + 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 connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, + autoFlushIntervalNanos, authorizationHeaderSupplier, + requestDurableAck ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE : DurableAckTiers.NONE, + cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, + reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, + initialConnectMode, errorHandler, errorInboxCapacity, + durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs, + connectionListener, connectionListenerInboxCapacity, + maxFrameRejections, poisonMinEscalationWindowMillis, + catchUpCapGapMinEscalationWindowMillis); + } + + public static QwpWebSocketSender connectWithCredentialSupplier( + List endpoints, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + Supplier authorizationHeaderSupplier, + 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, + boolean symbolDictResetEnabled, + int symbolDictResetThresholdSymbols, + long symbolDictResetMaxWaitMillis + ) { + return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, + autoFlushIntervalNanos, authorizationHeaderSupplier, + requestDurableAck ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE : DurableAckTiers.NONE, + cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, + reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, + initialConnectMode, errorHandler, errorInboxCapacity, + durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs, + connectionListener, connectionListenerInboxCapacity, + maxFrameRejections, poisonMinEscalationWindowMillis, + catchUpCapGapMinEscalationWindowMillis, + symbolDictResetEnabled, symbolDictResetThresholdSymbols, + symbolDictResetMaxWaitMillis, + CursorWebSocketSendLoop.DEFAULT_KEEPALIVE_INTERVAL_MILLIS); + } + /** * Master connect entry point — also accepts the poison-frame detector * threshold ({@code max_frame_rejections}) and the symbol-dictionary @@ -1270,7 +1742,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier( int autoFlushBytes, long autoFlushIntervalNanos, Supplier authorizationHeaderSupplier, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -1311,7 +1783,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier( authorizationHeaderSupplier ); try { - sender.requestDurableAck = requestDurableAck; + sender.durableAckTiers = durableAckTiers; sender.authTimeoutMs = authTimeoutMs; sender.connectTimeoutMs = connectTimeoutMs; sender.closeFlushTimeoutMillis = closeFlushTimeoutMillis; @@ -2000,6 +2472,15 @@ private void releaseCredentialLease() { } } + /** + * 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 @@ -3679,7 +4160,7 @@ public synchronized void startOrphanDrainers( reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - requestDurableAck, + durableAckTiers, durableAckKeepaliveIntervalMillis, maxFrameRejections, poisonMinEscalationWindowMillis, @@ -4248,7 +4729,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 @@ -4390,11 +4871,11 @@ 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( - ep.host, ep.port, null); + ep.host, ep.port, durableAckTiers, newClient.getServerDurableAckEcho()); if (terminalUpgradeError == null) { terminalUpgradeError = ackErr; } @@ -5028,7 +5509,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 0889d1ba..2e236811 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 7f04fa73..21d171ed 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;
@@ -294,6 +295,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
@@ -301,6 +307,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.
@@ -336,6 +347,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();
     // Sender-lifetime observability counters: acks, frames sent/replayed,
     // reconnect attempts and successes, server errors. A fresh instance per
@@ -628,6 +640,115 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
     // only suppresses errors the user already owned before close() began.
     private volatile LineSenderException synchronouslySurfacedError;
 
+    public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
+                                   long fsnAtZero, long parkNanos,
+                                   ReconnectFactory reconnectFactory,
+                                   long reconnectInitialBackoffMillis,
+                                   long reconnectMaxBackoffMillis,
+                                   boolean durableAckMode) {
+        this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
+                reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
+                durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE);
+    }
+
+    public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
+                                   long fsnAtZero, long parkNanos,
+                                   ReconnectFactory reconnectFactory,
+                                   long reconnectInitialBackoffMillis,
+                                   long reconnectMaxBackoffMillis,
+                                   boolean durableAckMode,
+                                   long durableAckKeepaliveIntervalMillis) {
+        this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
+                reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
+                durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE,
+                durableAckKeepaliveIntervalMillis);
+    }
+
+    public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
+                                   long fsnAtZero, long parkNanos,
+                                   ReconnectFactory reconnectFactory,
+                                   long reconnectInitialBackoffMillis,
+                                   long reconnectMaxBackoffMillis,
+                                   boolean durableAckMode,
+                                   long durableAckKeepaliveIntervalMillis,
+                                   int maxHeadFrameRejections) {
+        this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
+                reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
+                durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE,
+                durableAckKeepaliveIntervalMillis, maxHeadFrameRejections);
+    }
+
+    public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
+                                   long fsnAtZero, long parkNanos,
+                                   ReconnectFactory reconnectFactory,
+                                   long reconnectInitialBackoffMillis,
+                                   long reconnectMaxBackoffMillis,
+                                   boolean durableAckMode,
+                                   long durableAckKeepaliveIntervalMillis,
+                                   int maxHeadFrameRejections,
+                                   long poisonMinEscalationWindowMillis) {
+        this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
+                reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
+                durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE,
+                durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
+                poisonMinEscalationWindowMillis);
+    }
+
+    public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
+                                   long fsnAtZero, long parkNanos,
+                                   ReconnectFactory reconnectFactory,
+                                   long reconnectInitialBackoffMillis,
+                                   long reconnectMaxBackoffMillis,
+                                   boolean durableAckMode,
+                                   long durableAckKeepaliveIntervalMillis,
+                                   int maxHeadFrameRejections,
+                                   long poisonMinEscalationWindowMillis,
+                                   long catchUpCapGapMinEscalationWindowMillis) {
+        this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
+                reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
+                durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE,
+                durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
+                poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis);
+    }
+
+    public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
+                                   long fsnAtZero, long parkNanos,
+                                   ReconnectFactory reconnectFactory,
+                                   long reconnectInitialBackoffMillis,
+                                   long reconnectMaxBackoffMillis,
+                                   boolean durableAckMode,
+                                   long durableAckKeepaliveIntervalMillis,
+                                   int maxHeadFrameRejections,
+                                   long poisonMinEscalationWindowMillis,
+                                   long catchUpCapGapMinEscalationWindowMillis,
+                                   ReconnectPolicy reconnectPolicy) {
+        this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
+                reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
+                durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE,
+                durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
+                poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis,
+                reconnectPolicy, 0L);
+    }
+
+    public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
+                                   long fsnAtZero, long parkNanos,
+                                   ReconnectFactory reconnectFactory,
+                                   long reconnectInitialBackoffMillis,
+                                   long reconnectMaxBackoffMillis,
+                                   int durableAckTiers,
+                                   long durableAckKeepaliveIntervalMillis,
+                                   int maxHeadFrameRejections,
+                                   long poisonMinEscalationWindowMillis,
+                                   long catchUpCapGapMinEscalationWindowMillis,
+                                   ReconnectPolicy reconnectPolicy,
+                                   long externalFsnBase) {
+        this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
+                reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckTiers,
+                durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
+                poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis,
+                catchUpPolicyFor(reconnectPolicy), externalFsnBase);
+    }
+
     /**
      * Full constructor with explicit reconnect-policy knobs. When
      * {@code reconnectFactory} is non-null, the I/O thread treats wire
@@ -652,13 +773,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
@@ -669,10 +791,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);
     }
 
@@ -688,11 +810,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);
     }
 
@@ -710,12 +832,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);
     }
 
@@ -730,13 +852,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);
     }
@@ -763,14 +885,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, 0L);
@@ -786,7 +908,7 @@ private CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
                                    ReconnectFactory reconnectFactory,
                                    long reconnectInitialBackoffMillis,
                                    long reconnectMaxBackoffMillis,
-                                   boolean durableAckMode,
+                                   int durableAckTiers,
                                    long durableAckKeepaliveIntervalMillis,
                                    int maxHeadFrameRejections,
                                    long poisonMinEscalationWindowMillis,
@@ -949,7 +1071,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
@@ -982,17 +1105,14 @@ private CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
     }
 
     /**
-     * The policy-aware constructor as released in 1.3.x: equivalent to the overload
-     * below with {@code externalFsnBase = 0L}, the value every caller outside a
-     * symbol-dictionary recycle passes. Kept so callers compiled against a released
-     * jar keep linking.
+     * Tier-aware policy constructor with no external FSN offset.
      */
     public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
                                    long fsnAtZero, long parkNanos,
                                    ReconnectFactory reconnectFactory,
                                    long reconnectInitialBackoffMillis,
                                    long reconnectMaxBackoffMillis,
-                                   boolean durableAckMode,
+                                   int durableAckTiers,
                                    long durableAckKeepaliveIntervalMillis,
                                    int maxHeadFrameRejections,
                                    long poisonMinEscalationWindowMillis,
@@ -1000,7 +1120,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
                                    ReconnectPolicy reconnectPolicy) {
         this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
                 reconnectInitialBackoffMillis,
-                reconnectMaxBackoffMillis, durableAckMode,
+                reconnectMaxBackoffMillis, durableAckTiers,
                 durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
                 poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis,
                 reconnectPolicy, 0L);
@@ -1031,10 +1151,11 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
                                    long externalFsnBase) {
         this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
                 reconnectInitialBackoffMillis,
-                reconnectMaxBackoffMillis, durableAckMode,
+                reconnectMaxBackoffMillis,
+                durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE,
                 durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
                 poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis,
-                catchUpPolicyFor(reconnectPolicy), externalFsnBase);
+                reconnectPolicy, externalFsnBase);
     }
 
     private static CatchUpCapGapPolicy catchUpPolicyFor(ReconnectPolicy reconnectPolicy) {
@@ -1607,6 +1728,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
@@ -1898,6 +2041,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}).
@@ -1946,6 +2106,7 @@ private void clearDurableAckTracking() {
             releasePendingEntry(pendingDurable.pollFirst());
         }
         durableTableWatermarks.clear();
+        localDurableTableWatermarks.clear();
     }
 
     private static boolean isCatchUpCapGap(Throwable t) {
@@ -4239,6 +4400,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 4d463fa7..7ebdc5bc 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;
@@ -365,7 +366,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..2fd2b1e9 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;
@@ -120,11 +124,245 @@ public void testConnectStringOnRequiresServerSupport() throws Exception {
                 } catch (QwpDurableAckMismatchException e) {
                     Assert.assertEquals("localhost", e.getHost());
                     Assert.assertEquals(port, e.getPort());
+                    Assert.assertEquals(
+                            "WebSocket upgrade failed: server does not support durable ack [host=localhost, port=" + port
+                                    + ", requested=on, echoed=]",
+                            e.getMessage());
                 }
             }
         });
     }
 
+    @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());
+                    // The message names the requested set and that the server
+                    // answered with no confirmation header at all.
+                    Assert.assertEquals(
+                            "WebSocket upgrade failed: server does not support durable ack [host=localhost, port=" + port
+                                    + ", requested=local, echoed=]",
+                            e.getMessage());
+                }
+            }
+        });
+    }
+
+    @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());
+                    // The message names both sides of the mismatch: the
+                    // requested set and the foreign token the server echoed.
+                    Assert.assertEquals(
+                            "WebSocket upgrade failed: server does not support durable ack [host=localhost, port=" + port
+                                    + ", requested=local, echoed=enabled]",
+                            e.getMessage());
+                }
+            }
+        });
+    }
+
+    @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 +385,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 +407,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 +470,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/BackgroundDrainerLocalDurableAckTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerLocalDurableAckTest.java
new file mode 100644
index 00000000..ee378fcf
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerLocalDurableAckTest.java
@@ -0,0 +1,278 @@
+/*+*****************************************************************************
+ *     ___                  _   ____  ____
+ *    / _ \ _   _  ___  ___| |_|  _ \| __ )
+ *   | | | | | | |/ _ \/ __| __| | | |  _ \
+ *   | |_| | |_| |  __/\__ \ |_| |_| | |_) |
+ *    \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ *  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.sf.cursor;
+
+import io.questdb.client.cutlass.http.client.WebSocketClient;
+import io.questdb.client.cutlass.http.client.WebSocketClientFactory;
+import io.questdb.client.cutlass.qwp.client.DurableAckTiers;
+import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
+import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
+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.CursorWebSocketSendLoop;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
+import io.questdb.client.std.Files;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Paths;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Orphan-slot drain with the LOCAL durable-ack tier ({@code request_durable_ack=local}).
+ * 

+ * The {@link BackgroundDrainer} hands its configured tier set to every {@link CursorWebSocketSendLoop} it + * builds. With LOCAL alone, {@code STATUS_LOCAL_DURABLE_ACK} is the trim trigger: STATUS_OK must not trim + * the slot, and each local ack must advance the drainer's watermark until the slot is fully drained. A + * drainer that passed a different tier set would either trim on STATUS_OK (no tier) or treat local acks + * as progress only and never finish (replicated tier). + *

+ * Wire realism: a real {@link TestWebSocketServer} grants the requested {@code local} tier on upgrade, + * OKs every frame at once and sends the local acks only when the test releases them. + */ +public class BackgroundDrainerLocalDurableAckTest { + + private static final long FAST_BACKOFF_MAX_MILLIS = 4L; + private static final long FAST_BACKOFF_MILLIS = 1L; + private static final long RECONNECT_MAX_DURATION_MILLIS = 60_000L; + private static final int SEEDED_FRAMES = 5; + private static final long SEGMENT_SIZE_BYTES = 16384L; + private static final long SF_MAX_TOTAL_BYTES = 1L << 20; + private static final String TABLE = "trades"; + + private String slotPath; + + @Before + public void setUp() { + slotPath = Paths.get(System.getProperty("java.io.tmpdir"), + "qdb-drainer-local-ack-" + System.nanoTime()).toString(); + assertEquals("mkdir slot dir", 0, Files.mkdir(slotPath, Files.DIR_MODE_DEFAULT)); + } + + @After + public void tearDown() { + rmDirRec(slotPath); + } + + @Test + public void testOrphanSlotWithLocalTierTrimsOnLocalDurableAcks() throws Exception { + TestUtils.assertMemoryLeak(() -> { + seedSlot(); + LocalAckHoldingHandler handler = new LocalAckHoldingHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(handler, true)) { + server.start(); + assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + BackgroundDrainer drainer = new BackgroundDrainer( + slotPath, + SEGMENT_SIZE_BYTES, + SF_MAX_TOTAL_BYTES, + new LocalTierWireFactory(server.getPort()), + RECONNECT_MAX_DURATION_MILLIS, + FAST_BACKOFF_MILLIS, + FAST_BACKOFF_MAX_MILLIS, + DurableAckTiers.LOCAL, + /* durableAckKeepaliveIntervalMillis */ 200L); + Thread runner = new Thread(drainer, "test-local-ack-drainer"); + runner.setDaemon(true); + runner.start(); + try { + assertEquals("the server must see the local tier requested on upgrade", + "local", server.pollDurableAckRequest(5, TimeUnit.SECONDS)); + assertTrue("server did not receive and OK every orphan frame", + handler.allFramesOked.await(5, TimeUnit.SECONDS)); + // Give the drainer several 50ms polls to observe any OK-driven trim. + Thread.sleep(250); + assertEquals("STATUS_OK alone must not trim a LOCAL-tier orphan slot", + -1L, drainer.getAckedFsn()); + assertEquals(BackgroundDrainer.DrainOutcome.PENDING, drainer.outcome()); + + handler.sendLocalDurableAck(1); + awaitAckedFsn(drainer, 1); + assertEquals("a local ack covering frames 0..1 must trim exactly that far", + 1L, drainer.getAckedFsn()); + assertEquals(BackgroundDrainer.DrainOutcome.PENDING, drainer.outcome()); + + handler.sendLocalDurableAck(SEEDED_FRAMES - 1); + runner.join(10_000L); + } finally { + if (runner.isAlive()) { + drainer.requestStop(); + runner.join(5_000L); + } + } + + assertEquals("local acks must drain the orphan slot to completion", + BackgroundDrainer.DrainOutcome.SUCCESS, drainer.outcome()); + assertEquals(SEEDED_FRAMES - 1L, drainer.getAckedFsn()); + assertFalse("no .failed sentinel after a successful drain", + Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); + assertEquals("the drain must not have needed a reconnect", 1, server.handshakeCount()); + } + }); + } + + private static void awaitAckedFsn(BackgroundDrainer drainer, long expected) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (drainer.getAckedFsn() < expected && System.nanoTime() < deadline) { + Thread.sleep(10); + } + assertTrue("STATUS_LOCAL_DURABLE_ACK did not trim the LOCAL-tier orphan slot [expected=" + + expected + ", ackedFsn=" + drainer.getAckedFsn() + "]", + drainer.getAckedFsn() >= expected); + } + + private static void rmDirRec(String dir) { + if (dir == null || !Files.exists(dir)) return; + long find = Files.findFirst(dir); + if (find > 0) { + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + if (name != null && !".".equals(name) && !"..".equals(name)) { + String child = dir + "/" + name; + if (!Files.remove(child)) rmDirRec(child); + } + rc = Files.findNext(find); + } + } finally { + Files.findClose(find); + } + } + Files.remove(dir); + } + + private void seedSlot() { + try (CursorSendEngine engine = new CursorSendEngine(slotPath, SEGMENT_SIZE_BYTES)) { + long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT); + try { + byte[] payload = "frame-bytes-padd".getBytes(StandardCharsets.US_ASCII); + for (int i = 0; i < payload.length; i++) { + Unsafe.getUnsafe().putByte(buf + i, payload[i]); + } + for (int i = 0; i < SEEDED_FRAMES; i++) { + engine.appendBlocking(buf, 16); + } + } finally { + Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT); + } + } + } + + /** + * OKs every frame as it arrives (wire seq {@code n} carries table seqTxn {@code n}) and sends + * {@code STATUS_LOCAL_DURABLE_ACK} only when the test calls {@link #sendLocalDurableAck}. + */ + private static final class LocalAckHoldingHandler implements TestWebSocketServer.WebSocketServerHandler { + final CountDownLatch allFramesOked = new CountDownLatch(1); + private TestWebSocketServer.ClientHandler client; + private long nextSeq; + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + this.client = client; + long seq = nextSeq++; + try { + client.sendBinary(okFrame(seq)); + } catch (IOException e) { + throw new RuntimeException(e); + } + if (nextSeq == SEEDED_FRAMES) { + allFramesOked.countDown(); + } + } + + synchronized void sendLocalDurableAck(long seqTxn) throws IOException { + byte[] name = TABLE.getBytes(StandardCharsets.UTF_8); + ByteBuffer bb = ByteBuffer.allocate(1 + 2 + 2 + name.length + 8) + .order(ByteOrder.LITTLE_ENDIAN); + bb.put(WebSocketResponse.STATUS_LOCAL_DURABLE_ACK); + bb.putShort((short) 1); // tableCount + bb.putShort((short) name.length); + bb.put(name); + bb.putLong(seqTxn); + client.sendBinary(bb.array()); + } + + private static byte[] okFrame(long seq) { + byte[] name = TABLE.getBytes(StandardCharsets.UTF_8); + ByteBuffer bb = ByteBuffer.allocate(1 + 8 + 2 + 2 + name.length + 8) + .order(ByteOrder.LITTLE_ENDIAN); + bb.put(WebSocketResponse.STATUS_OK); + bb.putLong(seq); + bb.putShort((short) 1); // tableCount + bb.putShort((short) name.length); + bb.put(name); + bb.putLong(seq); + return bb.array(); + } + } + + /** + * Connects a real client requesting the LOCAL tier and, like the production connect walk, refuses a + * server that did not grant it. + */ + private static final class LocalTierWireFactory implements CursorWebSocketSendLoop.ReconnectFactory { + private final int port; + + LocalTierWireFactory(int port) { + this.port = port; + } + + @Override + public WebSocketClient reconnect() throws Exception { + WebSocketClient c = WebSocketClientFactory.newPlainTextInstance(); + try { + c.setQwpMaxVersion(1); + c.setQwpDurableAckTiers(DurableAckTiers.LOCAL); + c.setConnectTimeout(5_000); + c.connect("localhost", port); + c.upgrade("/write/v4", 5_000, null); + if (!c.isServerDurableAckEnabled()) { + throw new QwpDurableAckMismatchException( + "localhost", port, DurableAckTiers.LOCAL, c.getServerDurableAckEcho()); + } + } catch (Throwable t) { + c.close(); + throw t; + } + return c; + } + } +} 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 da3c79e9..7fad3495 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 2bae99e5..b2aef090 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 5aff5e5f..8446cda4 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, @@ -166,7 +167,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, @@ -224,7 +225,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, @@ -299,7 +300,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 5de5ada4..5ab67ab7 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, 0L); } 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 e7854c22..26463a46 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 @@ -66,6 +66,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; @@ -127,6 +131,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 @@ -389,6 +398,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; } @@ -718,6 +746,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 @@ -732,6 +761,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(); } } @@ -740,6 +771,7 @@ private boolean performHandshake() throws IOException { } upgradeRequestCount.incrementAndGet(); 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 @@ -798,7 +830,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 01fcd56a..39695ec2 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 @@ -59,7 +59,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);