diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 645d7b25..b996245c 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -34,6 +34,7 @@ import io.questdb.client.cutlass.line.http.AbstractLineHttpSender; import io.questdb.client.cutlass.line.tcp.DelegatingTlsChannel; import io.questdb.client.cutlass.line.tcp.PlainTcpLineChannel; +import io.questdb.client.cutlass.qwp.client.DurableAckTiers; import io.questdb.client.cutlass.qwp.client.QwpUdpSender; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; @@ -1145,7 +1146,7 @@ public int getConnectTimeout() { // max backoff (default 5_000) for the cursor I/O loop's exponential // retry-with-jitter loop. private long reconnectMaxDurationMillis = PARAMETER_NOT_SET_EXPLICITLY; - private boolean requestDurableAck; + private int durableAckTiers = DurableAckTiers.NONE; private int retryTimeoutMillis = PARAMETER_NOT_SET_EXPLICITLY; private boolean transactional; private String senderId = DEFAULT_SENDER_ID; @@ -1698,7 +1699,7 @@ public Sender build() { actualAutoFlushBytes, actualAutoFlushIntervalNanos, wsAuthHeader, - requestDurableAck, + durableAckTiers, cursorEngine, actualCloseFlushTimeoutMillis, actualReconnectMaxDurationMillis, @@ -2779,9 +2780,11 @@ public LineSenderBuilder reconnectMaxDurationMillis(long millis) { } /** - * Opts the connection in for STATUS_DURABLE_ACK frames. When enabled, - * servers with primary replication will emit per-table durable-upload - * watermarks as WAL data reaches the object store. + * Opts the connection in for STATUS_DURABLE_ACK frames, using the + * legacy "true" request token. Equivalent to + * {@code requestDurableAck("on")}: requests the replicated tier, so + * servers without primary replication deny the request and the + * sender fails at connect. *

* This setting is only supported for WebSocket transport. * @@ -2789,10 +2792,48 @@ public LineSenderBuilder reconnectMaxDurationMillis(long millis) { * @return this instance for method chaining */ public LineSenderBuilder requestDurableAck(boolean enabled) { + return requestDurableAckTiers(enabled + ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE + : DurableAckTiers.NONE); + } + + /** + * Requests durable-ack streams by tier set. Accepted values: + * {@code off}, {@code on} (legacy alias for the replicated tier), + * {@code local}, {@code replicated}, {@code local,replicated}. + *

+ * The server grants the full requested set or denies the request + * entirely (the sender then fails at connect); it never substitutes + * a weaker guarantee. + *

+ * This setting is only supported for WebSocket transport. + * + * @param tiers the requested tier set + * @return this instance for method chaining + */ + public LineSenderBuilder requestDurableAck(CharSequence tiers) { + int parsed = DurableAckTiers.parseConfigValue(tiers); + if (parsed < 0) { + throw new LineSenderException("invalid request_durable_ack [value=").put(tiers).put(", allowed-values=[on, off, local, replicated, local,replicated]]"); + } + return requestDurableAckTiers(parsed); + } + + private LineSenderBuilder requestDurableAckTiers(int tiers) { if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) { throw new LineSenderException("request_durable_ack is only supported for WebSocket transport"); } - this.requestDurableAck = enabled; + this.durableAckTiers = tiers; return this; } @@ -3762,13 +3803,11 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { throw new LineSenderException("request_durable_ack is only supported for WebSocket transport"); } pos = getValue(configurationString, pos, sink, "request_durable_ack"); - if (Chars.equalsIgnoreCase("on", sink)) { - requestDurableAck(true); - } else if (Chars.equalsIgnoreCase("off", sink)) { - requestDurableAck(false); - } else { - throw new LineSenderException("invalid request_durable_ack [value=").put(sink).put(", allowed-values=[on, off]]"); + int tiers = DurableAckTiers.parseConfigValue(sink); + if (tiers < 0) { + throw new LineSenderException("invalid request_durable_ack [value=").put(sink).put(", allowed-values=[on, off, local, replicated, local,replicated]]"); } + requestDurableAckTiers(tiers); } else if (Chars.equals("transaction", sink)) { if (protocol != PROTOCOL_WEBSOCKET) { throw new LineSenderException("transaction is only supported for WebSocket transport"); @@ -4180,13 +4219,11 @@ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString) } s = view.getStr("request_durable_ack"); if (s != null) { - if (s.equalsIgnoreCase("on")) { - requestDurableAck(true); - } else if (s.equalsIgnoreCase("off")) { - requestDurableAck(false); - } else { - throw new LineSenderException("invalid request_durable_ack [value=").put(s).put(", allowed-values=[on, off]]"); + int tiers = DurableAckTiers.parseConfigValue(s); + if (tiers < 0) { + throw new LineSenderException("invalid request_durable_ack [value=").put(s).put(", allowed-values=[on, off, local, replicated, local,replicated]]"); } + requestDurableAckTiers(tiers); } s = view.getStr("drain_orphans"); if (s != null) { @@ -4306,7 +4343,7 @@ public java.util.Map wsConfigSnapshotForTest() { m.put("auto_flush_interval", autoFlushIntervalMillis); m.put("max_name_len", maxNameLength); m.put("transaction", transactional); - m.put("request_durable_ack", requestDurableAck); + m.put("request_durable_ack", DurableAckTiers.configValue(durableAckTiers)); m.put("sender_id", senderId); m.put("sf_dir", sfDir); m.put("sf_max_segment_bytes", sfMaxSegmentBytes); @@ -4400,7 +4437,7 @@ private void validateParameters() { .put(", requestedCapacity=").put(bufferCapacity) .put("]"); } - if (requestDurableAck && protocol != PROTOCOL_WEBSOCKET) { + if (durableAckTiers != DurableAckTiers.NONE && protocol != PROTOCOL_WEBSOCKET) { throw new LineSenderException("request_durable_ack is only supported for WebSocket transport"); } if (protocol == PROTOCOL_HTTP) { diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java index 1290fcab..81859c47 100644 --- a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java @@ -25,6 +25,7 @@ package io.questdb.client.cutlass.http.client; import io.questdb.client.HttpClientConfiguration; +import io.questdb.client.cutlass.qwp.client.DurableAckTiers; import io.questdb.client.cutlass.qwp.client.QwpVersionMismatchException; import io.questdb.client.cutlass.qwp.websocket.WebSocketCloseCode; import io.questdb.client.cutlass.qwp.websocket.WebSocketFrameParser; @@ -80,7 +81,6 @@ public abstract class WebSocketClient implements QuietCloseable { private static final String QUESTDB_ROLE_HEADER_NAME = "X-QuestDB-Role:"; private static final String QUESTDB_ZONE_HEADER_NAME = "X-QuestDB-Zone:"; private static final String QWP_CONTENT_ENCODING_HEADER_NAME = "X-QWP-Content-Encoding:"; - private static final String QWP_DURABLE_ACK_ENABLED_VALUE = "enabled"; private static final String QWP_DURABLE_ACK_HEADER_NAME = "X-QWP-Durable-Ack:"; private static final String QWP_MAX_BATCH_SIZE_HEADER_NAME = "X-QWP-Max-Batch-Size:"; private static final String QWP_VERSION_HEADER_NAME = "X-QWP-Version:"; @@ -137,7 +137,7 @@ public abstract class WebSocketClient implements QuietCloseable { private int qwpMaxBatchRows; private int qwpMaxVersion = 1; // Opt-in for STATUS_DURABLE_ACK frames; sent as X-QWP-Request-Durable-Ack: true - private boolean qwpRequestDurableAck; + private int qwpDurableAckTiers = DurableAckTiers.NONE; // Receive buffer (native memory) private long recvBufPtr; private int recvBufSize; @@ -586,13 +586,14 @@ public void setQwpMaxVersion(int maxVersion) { } /** - * Enables the opt-in X-QWP-Request-Durable-Ack upgrade header. When set, - * servers with primary replication configured will additionally emit - * STATUS_DURABLE_ACK frames as the WAL containing committed client - * messages reaches the object store. + * Sets the requested durable-ack tier set ({@link DurableAckTiers} + * bitmask) for the opt-in X-QWP-Request-Durable-Ack upgrade header. When + * granted, the server emits STATUS_DURABLE_ACK frames (replicated tier) + * and/or STATUS_LOCAL_DURABLE_ACK frames (local tier) as commits reach + * the corresponding durability frontier. */ - public void setQwpRequestDurableAck(boolean enabled) { - this.qwpRequestDurableAck = enabled; + public void setQwpDurableAckTiers(int tiers) { + this.qwpDurableAckTiers = tiers; } /** @@ -694,8 +695,10 @@ public void upgrade(CharSequence path, int timeout, CharSequence authorizationHe sendBuffer.putAscii(Integer.toString(qwpMaxBatchRows)); sendBuffer.putAscii("\r\n"); } - if (qwpRequestDurableAck) { - sendBuffer.putAscii("X-QWP-Request-Durable-Ack: true\r\n"); + if (qwpDurableAckTiers != DurableAckTiers.NONE) { + sendBuffer.putAscii("X-QWP-Request-Durable-Ack: "); + sendBuffer.putAscii(DurableAckTiers.requestHeaderValue(qwpDurableAckTiers)); + sendBuffer.putAscii("\r\n"); } if (authorizationHeader != null) { sendBuffer.putAscii("Authorization: "); @@ -798,7 +801,10 @@ private static int extractContentEncodingZstdLevel(String response) { return 0; } - private static boolean extractDurableAckEnabled(String response) { + private static boolean extractDurableAckConfirmed(String response, String expectedToken) { + if (expectedToken == null) { + return false; + } int headerLen = QWP_DURABLE_ACK_HEADER_NAME.length(); int responseLen = response.length(); for (int i = 0; i <= responseLen - headerLen; i++) { @@ -809,7 +815,11 @@ private static boolean extractDurableAckEnabled(String response) { lineEnd = responseLen; } String value = response.substring(valueStart, lineEnd).trim(); - return value.equalsIgnoreCase(QWP_DURABLE_ACK_ENABLED_VALUE); + // The server echoes the granted set verbatim (or the + // "enabled" token for a legacy "true" request); anything + // else is a partial or foreign grant and counts as a + // denial -- all-or-nothing, never a silent downgrade. + return value.equalsIgnoreCase(expectedToken); } } return false; @@ -1390,7 +1400,8 @@ private void validateUpgradeResponse(int headerEnd) { // Only meaningful when qwpRequestDurableAck is true; the sender // checks this value to fail at connect rather than silently // missing trim signals. - serverDurableAckEnabled = extractDurableAckEnabled(response); + serverDurableAckEnabled = extractDurableAckConfirmed( + response, DurableAckTiers.expectedConfirmToken(qwpDurableAckTiers)); // Extract X-QWP-Max-Batch-Size (optional). Older servers omit it; the // sender falls back to its locally configured byte budget in that case. diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/DurableAckTiers.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/DurableAckTiers.java new file mode 100644 index 00000000..b3648cbe --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/DurableAckTiers.java @@ -0,0 +1,162 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2024 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +/** + * The QWP durable-ack tier set a sender can request, encoded as a bitmask: + *

+ * {@link #LEGACY_TRUE} is a modifier bit for requests made through the + * boolean {@code requestDurableAck(true)} API or the {@code on} config + * value: the request header carries the literal {@code "true"} and the + * server confirms the grant with the {@code "enabled"} token. It always + * combines with {@link #REPLICATED}. + *

+ * The server grants the full requested set or denies the request entirely + * (no confirmation header); it never substitutes a weaker guarantee. The + * sender trims its store-and-forward copy on the strongest requested tier's + * ack; with both tiers requested, local acks arrive as progress signals only. + */ +public final class DurableAckTiers { + + public static final int NONE = 0; + public static final int LOCAL = 1; + public static final int REPLICATED = 2; + // Modifier bit, only ever combined with REPLICATED: send the "true" + // request token and expect the "enabled" confirmation. + public static final int LEGACY_TRUE = 4; + + private DurableAckTiers() { + } + + /** + * The config-string form of a tier set, the inverse of + * {@link #parseConfigValue(CharSequence)} (legacy sets print as "on"). + */ + public static String configValue(int tiers) { + if ((tiers & LEGACY_TRUE) != 0) { + return "on"; + } + switch (tiers & (LOCAL | REPLICATED)) { + case LOCAL: + return "local"; + case REPLICATED: + return "replicated"; + case LOCAL | REPLICATED: + return "local,replicated"; + default: + return "off"; + } + } + + /** + * The X-QWP-Durable-Ack confirmation token the server must echo for this + * request, or null when no tier is requested. A legacy request expects + * the {@code "enabled"} token; explicit tier requests expect their own + * token set back verbatim. + */ + public static String expectedConfirmToken(int tiers) { + if ((tiers & LEGACY_TRUE) != 0) { + return "enabled"; + } + return explicitToken(tiers); + } + + public static boolean hasLocal(int tiers) { + return (tiers & LOCAL) != 0; + } + + public static boolean hasReplicated(int tiers) { + return (tiers & REPLICATED) != 0; + } + + /** + * True when the sender's trim trigger is {@code STATUS_LOCAL_DURABLE_ACK}: + * the local tier is requested without the replicated one. Any request + * including the replicated tier trims on {@code STATUS_DURABLE_ACK} — the + * strongest requested guarantee wins. + */ + public static boolean isTrimOnLocalAck(int tiers) { + return hasLocal(tiers) && !hasReplicated(tiers); + } + + /** + * Parses a {@code request_durable_ack} value into a tier set, or -1 for + * an unrecognized value. {@code on} maps to the replicated tier with + * {@link #LEGACY_TRUE} set; {@code off} is {@link #NONE}. + */ + public static int parseConfigValue(CharSequence value) { + if (value == null) { + return -1; + } + String v = value.toString().trim(); + if (v.equalsIgnoreCase("off")) { + return NONE; + } + if (v.equalsIgnoreCase("on")) { + return REPLICATED | LEGACY_TRUE; + } + if (v.equalsIgnoreCase("local")) { + return LOCAL; + } + if (v.equalsIgnoreCase("replicated")) { + return REPLICATED; + } + if (v.equalsIgnoreCase("local,replicated") || v.equalsIgnoreCase("replicated,local")) { + return LOCAL | REPLICATED; + } + return -1; + } + + /** + * The X-QWP-Request-Durable-Ack header value for a tier set, or null when + * no tier is requested. A legacy set sends the literal {@code "true"}, + * the only request value servers without tier support recognize. + */ + public static String requestHeaderValue(int tiers) { + if ((tiers & LEGACY_TRUE) != 0) { + return "true"; + } + return explicitToken(tiers); + } + + private static String explicitToken(int tiers) { + switch (tiers & (LOCAL | REPLICATED)) { + case LOCAL: + return "local"; + case REPLICATED: + return "replicated"; + case LOCAL | REPLICATED: + return "local,replicated"; + default: + return null; + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 41cc0a8c..c94e9488 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -384,7 +384,7 @@ public class QwpWebSocketSender implements Sender { // values; Sender.build can override via the new connect overload. private long reconnectMaxDurationMillis = CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS; - private boolean requestDurableAck; + private int durableAckTiers; // Monotonic per-attempt counter snapshotted onto every connection event // fired from buildAndConnect. Counts every FOREGROUND endpoint try -- // successes and failures alike -- across this sender's lifetime. @@ -493,7 +493,7 @@ public static QwpWebSocketSender connect(String host, int port, ClientTlsConfigu host, port, tlsConfig, DEFAULT_AUTO_FLUSH_ROWS, DEFAULT_AUTO_FLUSH_BYTES, DEFAULT_AUTO_FLUSH_INTERVAL_NANOS, null, - false, engine + DurableAckTiers.NONE, engine ); } catch (Throwable t) { try { @@ -518,12 +518,12 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine ) { return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, authorizationHeader, - requestDurableAck, cursorEngine, 5_000L); + durableAckTiers, cursorEngine, 5_000L); } /** @@ -540,13 +540,13 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis ) { return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, authorizationHeader, - requestDurableAck, cursorEngine, + durableAckTiers, cursorEngine, closeFlushTimeoutMillis, CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS, CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS, @@ -567,7 +567,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -576,7 +576,7 @@ public static QwpWebSocketSender connect( ) { return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, authorizationHeader, - requestDurableAck, cursorEngine, + durableAckTiers, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, Sender.InitialConnectMode.OFF); @@ -597,7 +597,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -607,7 +607,7 @@ public static QwpWebSocketSender connect( ) { return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, authorizationHeader, - requestDurableAck, cursorEngine, + durableAckTiers, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, null, SenderErrorDispatcher.DEFAULT_CAPACITY); @@ -626,7 +626,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -638,7 +638,7 @@ public static QwpWebSocketSender connect( ) { return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, authorizationHeader, - requestDurableAck, cursorEngine, + durableAckTiers, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, errorHandler, errorInboxCapacity, @@ -659,7 +659,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -674,7 +674,7 @@ public static QwpWebSocketSender connect( singleEndpoint(host, port), tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, authorizationHeader, - requestDurableAck, cursorEngine, + durableAckTiers, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, errorHandler, errorInboxCapacity, @@ -697,7 +697,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -711,7 +711,7 @@ public static QwpWebSocketSender connect( ) { return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), - requestDurableAck, cursorEngine, + durableAckTiers, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, errorHandler, errorInboxCapacity, @@ -737,7 +737,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -754,7 +754,7 @@ public static QwpWebSocketSender connect( ) { return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), - requestDurableAck, cursorEngine, + durableAckTiers, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, errorHandler, errorInboxCapacity, @@ -780,7 +780,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier( int autoFlushBytes, long autoFlushIntervalNanos, Supplier authorizationHeaderSupplier, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -796,7 +796,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier( int connectionListenerInboxCapacity ) { return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, - autoFlushIntervalNanos, authorizationHeaderSupplier, requestDurableAck, + autoFlushIntervalNanos, authorizationHeaderSupplier, durableAckTiers, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, errorHandler, errorInboxCapacity, @@ -825,7 +825,7 @@ public static QwpWebSocketSender connect( int autoFlushBytes, long autoFlushIntervalNanos, String authorizationHeader, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -845,7 +845,7 @@ public static QwpWebSocketSender connect( ) { return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), - requestDurableAck, cursorEngine, + durableAckTiers, cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, errorHandler, errorInboxCapacity, @@ -855,6 +855,366 @@ autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), catchUpCapGapMinEscalationWindowMillis); } + // ------------------------------------------------------------------ + // Boolean durable-ack overloads. Each maps its boolean flag onto the + // DurableAckTiers bitmask the master entry point takes: true becomes + // the legacy request (REPLICATED | LEGACY_TRUE -- header value "true", + // confirmed by the "enabled" token), false becomes NONE. They keep + // binary compatibility for callers linked against the boolean + // signatures. + // ------------------------------------------------------------------ + + public static QwpWebSocketSender connect( + String host, + int port, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + String authorizationHeader, + boolean requestDurableAck, + CursorSendEngine cursorEngine + ) { + return connect( + host, + port, + tlsConfig, + autoFlushRows, + autoFlushBytes, + autoFlushIntervalNanos, + authorizationHeader, + requestDurableAck + ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE + : DurableAckTiers.NONE, + cursorEngine + ); + } + + public static QwpWebSocketSender connect( + String host, + int port, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + String authorizationHeader, + boolean requestDurableAck, + CursorSendEngine cursorEngine, + long closeFlushTimeoutMillis + ) { + return connect( + host, + port, + tlsConfig, + autoFlushRows, + autoFlushBytes, + autoFlushIntervalNanos, + authorizationHeader, + requestDurableAck + ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE + : DurableAckTiers.NONE, + cursorEngine, + closeFlushTimeoutMillis + ); + } + + public static QwpWebSocketSender connect( + String host, + int port, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + String authorizationHeader, + boolean requestDurableAck, + CursorSendEngine cursorEngine, + long closeFlushTimeoutMillis, + long reconnectMaxDurationMillis, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis + ) { + return connect( + host, + port, + tlsConfig, + autoFlushRows, + autoFlushBytes, + autoFlushIntervalNanos, + authorizationHeader, + requestDurableAck + ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE + : DurableAckTiers.NONE, + cursorEngine, + closeFlushTimeoutMillis, + reconnectMaxDurationMillis, + reconnectInitialBackoffMillis, + reconnectMaxBackoffMillis + ); + } + + public static QwpWebSocketSender connect( + String host, + int port, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + String authorizationHeader, + boolean requestDurableAck, + CursorSendEngine cursorEngine, + long closeFlushTimeoutMillis, + long reconnectMaxDurationMillis, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + Sender.InitialConnectMode initialConnectMode + ) { + return connect( + host, + port, + tlsConfig, + autoFlushRows, + autoFlushBytes, + autoFlushIntervalNanos, + authorizationHeader, + requestDurableAck + ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE + : DurableAckTiers.NONE, + cursorEngine, + closeFlushTimeoutMillis, + reconnectMaxDurationMillis, + reconnectInitialBackoffMillis, + reconnectMaxBackoffMillis, + initialConnectMode + ); + } + + public static QwpWebSocketSender connect( + String host, + int port, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + String authorizationHeader, + boolean requestDurableAck, + CursorSendEngine cursorEngine, + long closeFlushTimeoutMillis, + long reconnectMaxDurationMillis, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + Sender.InitialConnectMode initialConnectMode, + SenderErrorHandler errorHandler, + int errorInboxCapacity + ) { + return connect( + host, + port, + tlsConfig, + autoFlushRows, + autoFlushBytes, + autoFlushIntervalNanos, + authorizationHeader, + requestDurableAck + ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE + : DurableAckTiers.NONE, + cursorEngine, + closeFlushTimeoutMillis, + reconnectMaxDurationMillis, + reconnectInitialBackoffMillis, + reconnectMaxBackoffMillis, + initialConnectMode, + errorHandler, + errorInboxCapacity + ); + } + + public static QwpWebSocketSender connect( + String host, + int port, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + String authorizationHeader, + boolean requestDurableAck, + CursorSendEngine cursorEngine, + long closeFlushTimeoutMillis, + long reconnectMaxDurationMillis, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + Sender.InitialConnectMode initialConnectMode, + SenderErrorHandler errorHandler, + int errorInboxCapacity, + long durableAckKeepaliveIntervalMillis + ) { + return connect( + host, + port, + tlsConfig, + autoFlushRows, + autoFlushBytes, + autoFlushIntervalNanos, + authorizationHeader, + requestDurableAck + ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE + : DurableAckTiers.NONE, + cursorEngine, + closeFlushTimeoutMillis, + reconnectMaxDurationMillis, + reconnectInitialBackoffMillis, + reconnectMaxBackoffMillis, + initialConnectMode, + errorHandler, + errorInboxCapacity, + durableAckKeepaliveIntervalMillis + ); + } + + public static QwpWebSocketSender connect( + List endpoints, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + String authorizationHeader, + boolean requestDurableAck, + CursorSendEngine cursorEngine, + long closeFlushTimeoutMillis, + long reconnectMaxDurationMillis, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + Sender.InitialConnectMode initialConnectMode, + SenderErrorHandler errorHandler, + int errorInboxCapacity, + long durableAckKeepaliveIntervalMillis, + long authTimeoutMs + ) { + return connect( + endpoints, + tlsConfig, + autoFlushRows, + autoFlushBytes, + autoFlushIntervalNanos, + authorizationHeader, + requestDurableAck + ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE + : DurableAckTiers.NONE, + cursorEngine, + closeFlushTimeoutMillis, + reconnectMaxDurationMillis, + reconnectInitialBackoffMillis, + reconnectMaxBackoffMillis, + initialConnectMode, + errorHandler, + errorInboxCapacity, + durableAckKeepaliveIntervalMillis, + authTimeoutMs + ); + } + + public static QwpWebSocketSender connect( + List endpoints, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + String authorizationHeader, + boolean requestDurableAck, + CursorSendEngine cursorEngine, + long closeFlushTimeoutMillis, + long reconnectMaxDurationMillis, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + Sender.InitialConnectMode initialConnectMode, + SenderErrorHandler errorHandler, + int errorInboxCapacity, + long durableAckKeepaliveIntervalMillis, + long authTimeoutMs, + int connectTimeoutMs, + SenderConnectionListener connectionListener, + int connectionListenerInboxCapacity + ) { + return connect( + endpoints, + tlsConfig, + autoFlushRows, + autoFlushBytes, + autoFlushIntervalNanos, + authorizationHeader, + requestDurableAck + ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE + : DurableAckTiers.NONE, + cursorEngine, + closeFlushTimeoutMillis, + reconnectMaxDurationMillis, + reconnectInitialBackoffMillis, + reconnectMaxBackoffMillis, + initialConnectMode, + errorHandler, + errorInboxCapacity, + durableAckKeepaliveIntervalMillis, + authTimeoutMs, + connectTimeoutMs, + connectionListener, + connectionListenerInboxCapacity + ); + } + + public static QwpWebSocketSender connect( + List endpoints, + ClientTlsConfiguration tlsConfig, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + String authorizationHeader, + boolean requestDurableAck, + CursorSendEngine cursorEngine, + long closeFlushTimeoutMillis, + long reconnectMaxDurationMillis, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + Sender.InitialConnectMode initialConnectMode, + SenderErrorHandler errorHandler, + int errorInboxCapacity, + long durableAckKeepaliveIntervalMillis, + long authTimeoutMs, + int connectTimeoutMs, + SenderConnectionListener connectionListener, + int connectionListenerInboxCapacity, + int maxFrameRejections, + long poisonMinEscalationWindowMillis, + long catchUpCapGapMinEscalationWindowMillis + ) { + return connect( + endpoints, + tlsConfig, + autoFlushRows, + autoFlushBytes, + autoFlushIntervalNanos, + authorizationHeader, + requestDurableAck + ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE + : DurableAckTiers.NONE, + cursorEngine, + closeFlushTimeoutMillis, + reconnectMaxDurationMillis, + reconnectInitialBackoffMillis, + reconnectMaxBackoffMillis, + initialConnectMode, + errorHandler, + errorInboxCapacity, + durableAckKeepaliveIntervalMillis, + authTimeoutMs, + connectTimeoutMs, + connectionListener, + connectionListenerInboxCapacity, + maxFrameRejections, + poisonMinEscalationWindowMillis, + catchUpCapGapMinEscalationWindowMillis + ); + } + + /** * Master connect entry point — also accepts the poison-frame detector * threshold ({@code max_frame_rejections}): consecutive server-active @@ -873,7 +1233,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier( int autoFlushBytes, long autoFlushIntervalNanos, Supplier authorizationHeaderSupplier, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -897,7 +1257,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier( authorizationHeaderSupplier ); try { - sender.requestDurableAck = requestDurableAck; + sender.durableAckTiers = durableAckTiers; sender.authTimeoutMs = authTimeoutMs; sender.connectTimeoutMs = connectTimeoutMs; sender.closeFlushTimeoutMillis = closeFlushTimeoutMillis; @@ -1511,6 +1871,15 @@ public boolean isCloseCleanupComplete() { return closeCleanupComplete; } + /** + * The store-and-forward send loop, for asserting ack/trim counters in + * integration tests. Null until the first connect. + */ + @TestOnly + public CursorWebSocketSendLoop cursorSendLoopForTest() { + return cursorSendLoop; + } + /** * True once the store-and-forward slot flock has been released. False * means an I/O or manager worker did not stop and close() retained the @@ -2875,7 +3244,7 @@ public synchronized void startOrphanDrainers( reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - requestDurableAck, + durableAckTiers, durableAckKeepaliveIntervalMillis, maxFrameRejections, poisonMinEscalationWindowMillis, @@ -3371,7 +3740,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx, CursorWebSocketSendLo try { newClient.setQwpMaxVersion(QwpConstants.VERSION); newClient.setQwpClientId(QwpConstants.CLIENT_ID); - newClient.setQwpRequestDurableAck(requestDurableAck); + newClient.setQwpDurableAckTiers(durableAckTiers); newClient.setConnectTimeout(effectiveConnectTimeoutMs(background, connectTimeoutMs)); if (cancellation != null) { // Publish the client we are about to block on so a @@ -3499,7 +3868,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx, CursorWebSocketSendLo // rethrow. close() is CAS-gated, so re-closing after the // durable-ack arm's own close is a no-op. try { - if (requestDurableAck && !newClient.isServerDurableAckEnabled()) { + if (durableAckTiers != DurableAckTiers.NONE && !newClient.isServerDurableAckEnabled()) { newClient.close(); hostTracker.recordRoleReject(idx, false, !background); QwpDurableAckMismatchException ackErr = new QwpDurableAckMismatchException( @@ -4065,7 +4434,7 @@ private void ensureConnected() { reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - requestDurableAck, + durableAckTiers, durableAckKeepaliveIntervalMillis, maxFrameRejections, poisonMinEscalationWindowMillis, diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java index 81d59d28..9b6cb8e2 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java @@ -45,7 +45,7 @@ * +--------+----------+------------+--------------------------------------+ * *

- * STATUS_DURABLE_ACK response format: + * STATUS_DURABLE_ACK / STATUS_LOCAL_DURABLE_ACK response format: *

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