From 7a05cb6ecf3c940c539c6df4878629c9ed19c576 Mon Sep 17 00:00:00 2001 From: Eugene Lysiuchenko <79919431+eugenels@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:58:21 +0300 Subject: [PATCH 1/4] Add local durable ack tier to QWP sender The sender can now request durable acks by tier set instead of a single boolean. request_durable_ack accepts on, off, local, replicated and local,replicated; "on" keeps its shipped meaning as the legacy alias for the replicated tier and still travels as the literal "true" header value with the historical "enabled" confirmation, so nothing changes on the wire for existing configurations. DurableAckTiers encodes the set as a bitmask and owns the header token and confirmation-token mapping. WebSocketClient sends the requested token and validates that the server echoes the granted set verbatim -- the grant is all-or-nothing, a partial or foreign echo counts as a denial and fails the connect with the existing mismatch error. WebSocketResponse recognizes the new STATUS_LOCAL_DURABLE_ACK (0x0E) frame, which shares the STATUS_DURABLE_ACK payload layout. The send loop trims its store-and-forward copy on the strongest requested tier's ack: local-only requests trim on the local ack through the same watermark and drain path; requests including the replicated tier keep trimming on STATUS_DURABLE_ACK and record local acks as progress signals in a separate watermark map. The loop exposes getTotalLocalDurableAcks() and the sender exposes its send loop to tests so server-side integration tests can assert the ack and trim counters end to end. --- .../main/java/io/questdb/client/Sender.java | 77 ++++++--- .../cutlass/http/client/WebSocketClient.java | 37 ++-- .../cutlass/qwp/client/DurableAckTiers.java | 161 ++++++++++++++++++ .../qwp/client/QwpWebSocketSender.java | 65 ++++--- .../cutlass/qwp/client/WebSocketResponse.java | 53 +++++- .../client/sf/cursor/BackgroundDrainer.java | 19 ++- .../sf/cursor/CursorWebSocketSendLoop.java | 94 ++++++++-- .../qwp/client/DeltaDictRecoveryTest.java | 15 +- .../qwp/client/MmapFaultDegradesTest.java | 5 +- ...QwpWebSocketSenderJvmErrorCleanupTest.java | 3 +- .../qwp/client/QwpWebSocketSenderTest.java | 3 +- .../qwp/client/SelfSufficientFramesTest.java | 19 ++- .../client/SlotLockReleasedContractTest.java | 5 +- ...oundDrainerCredentialOutageReportTest.java | 5 +- .../BackgroundDrainerDurableAckRetryTest.java | 3 +- ...roundDrainerInterruptIsStopSignalTest.java | 3 +- ...kgroundDrainerInterruptedTeardownTest.java | 3 +- ...ckgroundDrainerMidDrainAuthRejectTest.java | 5 +- ...roundDrainerMidDrainCapabilityGapTest.java | 5 +- .../BackgroundDrainerOrphanTailTest.java | 5 +- ...roundDrainerPoolConnectPhaseCloseTest.java | 3 +- ...groundDrainerPoolInterruptedCloseTest.java | 3 +- .../BackgroundDrainerSetupFailureTest.java | 13 +- ...undDrainerTransportOutageRecoveryTest.java | 5 +- ...DrainerUnreplayableSlotQuarantineTest.java | 5 +- .../sf/cursor/CloseOwnershipRaceTest.java | 3 +- ...WebSocketSendLoopBlockedSendCloseTest.java | 5 +- ...WebSocketSendLoopCatchUpAlignmentTest.java | 9 +- ...ebSocketSendLoopConnectPhaseCloseTest.java | 5 +- ...orWebSocketSendLoopDurableAckFuzzTest.java | 3 +- ...CursorWebSocketSendLoopDurableAckTest.java | 5 +- ...SendLoopForegroundReconnectPolicyTest.java | 11 +- ...ocketSendLoopInterruptedCloseLeakTest.java | 3 +- ...CursorWebSocketSendLoopMirrorLeakTest.java | 3 +- ...ursorWebSocketSendLoopPoisonFrameTest.java | 15 +- ...rsorWebSocketSendLoopRotationRaceTest.java | 3 +- .../EngineCloseSlotLockReleaseTest.java | 3 +- .../client/sf/cursor/OrphanScannerTest.java | 3 +- 38 files changed, 517 insertions(+), 168 deletions(-) create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/DurableAckTiers.java diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 645d7b254..b474e1442 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")}: the shipped meaning is 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 1290fcab5..b34eb76a2 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 + // historical "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 000000000..e6259914d --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/DurableAckTiers.java @@ -0,0 +1,161 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 recording that the request travels + * as the shipped literal {@code "true"} header value, whose grant the server + * confirms with the historical {@code "enabled"} token. It always combines + * with {@link #REPLICATED} — the shipped meaning of the legacy opt-in. + *

+ * 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 legacy "true" + // request token and expect the historical "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 historical {@code "enabled"} token; explicit 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} is the legacy alias for the + * replicated tier (its shipped meaning); {@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 shipped literal + * {@code "true"} so old servers keep recognizing it. + */ + 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 41cc0a8c9..f3e40e410 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, @@ -873,7 +873,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier( int autoFlushBytes, long autoFlushIntervalNanos, Supplier authorizationHeaderSupplier, - boolean requestDurableAck, + int durableAckTiers, CursorSendEngine cursorEngine, long closeFlushTimeoutMillis, long reconnectMaxDurationMillis, @@ -897,7 +897,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier( authorizationHeaderSupplier ); try { - sender.requestDurableAck = requestDurableAck; + sender.durableAckTiers = durableAckTiers; sender.authTimeoutMs = authTimeoutMs; sender.connectTimeoutMs = connectTimeoutMs; sender.closeFlushTimeoutMillis = closeFlushTimeoutMillis; @@ -1511,6 +1511,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 +2884,7 @@ public synchronized void startOrphanDrainers( reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - requestDurableAck, + durableAckTiers, durableAckKeepaliveIntervalMillis, maxFrameRejections, poisonMinEscalationWindowMillis, @@ -3371,7 +3380,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 +3508,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 +4074,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 81d59d28f..9b6cb8e24 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 facd872bb..57bc08f3a 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 6643bf036..e01cbcfff 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,16 @@ 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();
+    }
+
     /**
      * Total times a durable-ack frame caused {@link CursorSendEngine#acknowledge}
      * to advance. Always 0 when {@code durableAckMode} is false. A non-zero
@@ -1680,6 +1704,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 +1745,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 +3974,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 66ba07497..fbc833d86 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/MmapFaultDegradesTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/MmapFaultDegradesTest.java
index 360cbf5e9..6e0bf773f 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 d0eb80db0..0eee60638 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;
@@ -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 3911f5c05..d90fd478a 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 e305210b1..1d1aaebfa 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 2ae605b7f..afcdf4f16 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/sf/cursor/BackgroundDrainerCredentialOutageReportTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerCredentialOutageReportTest.java
index 7cbffe821..3019e1f2a 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 fb6bbfb23..6b0947892 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 a73aef26f..578142f98 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 d1526146a..22e7e898d 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 db5efa91d..e8c221cf7 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 6f2224a14..4881ce23d 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 99f598172..895c489ee 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 d31f60d47..3f2a5e125 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 d8a3e82ec..c6cecd020 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 a8bd96c9d..ceaf80b2e 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 86a3decc5..f32783218 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 a67a805dd..c7a56af8f 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 fc1b9257b..40dcd7933 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 385785866..f6a3973b8 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 ec828aae2..54eafea39 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 77e6028d1..b863b561b 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 11c6388e6..329d3f311 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 80cb68b51..b52ed76a8 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;
@@ -668,7 +669,7 @@ private CursorWebSocketSendLoop newDefaultLoop(CursorSendEngine engine) {
                 () -> {
                     throw new UnsupportedOperationException("test loop is never started");
                 },
-                100L, 5_000L, false);
+                100L, 5_000L, DurableAckTiers.NONE);
     }
 
     private CursorWebSocketSendLoop newDurableLoop(CursorSendEngine engine) {
@@ -677,7 +678,7 @@ private CursorWebSocketSendLoop newDurableLoop(CursorSendEngine engine) {
                 () -> {
                     throw new UnsupportedOperationException("test loop is never started");
                 },
-                100L, 5_000L, true);
+                100L, 5_000L, DurableAckTiers.REPLICATED);
     }
 
     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 902df846b..bc3a47e0e 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 ad17a1cab..9e49144ab 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 b215e476a..cc2b4f58b 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 1f446a113..25b128139 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 afe6baaab..c0b896a3d 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 0c78e7777..b215185e3 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 226cc4b9c..09dbda343 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());

From 2babfc8fb0ace66de92ae653c3181080623cefd5 Mon Sep 17 00:00:00 2001
From: Eugene Lysiuchenko <79919431+eugenels@users.noreply.github.com>
Date: Fri, 11 Sep 2026 13:24:15 +0300
Subject: [PATCH 2/4] Restore boolean connect overloads for binary compat

ExportedApiCompatibilityTest rightly rejected retyping the public
QwpWebSocketSender.connect overloads' durable-ack parameter from
boolean to the DurableAckTiers bitmask: callers compiled against
earlier releases would break with NoSuchMethodError. The nine
pre-branch boolean signatures return as delegating overloads that map
true to the legacy "true" request (replicated tier, "enabled"
confirmation) -- the shipped meaning of the opt-in.

WsSenderConfigHonoredTest now expects the config-token string the
snapshot reports ("on", "local", "replicated", "local,replicated")
and covers the new values. The JVM-error cleanup test follows the
renamed durableAckTiers field in its reflective setup.
---
 .../qwp/client/QwpWebSocketSender.java        | 359 ++++++++++++++++++
 ...QwpWebSocketSenderJvmErrorCleanupTest.java |   2 +-
 .../test/impl/WsSenderConfigHonoredTest.java  |   5 +-
 3 files changed, 364 insertions(+), 2 deletions(-)

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 f3e40e410..3e5a78d32 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
@@ -855,6 +855,365 @@ autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader),
                 catchUpCapGapMinEscalationWindowMillis);
     }
 
+    // ------------------------------------------------------------------
+    // Binary-compatibility overloads. The durable-ack parameter was retyped
+    // from boolean to the DurableAckTiers bitmask; callers compiled against
+    // earlier releases still link against these boolean variants, which map
+    // true to the legacy "true" request (the replicated tier, confirmed by
+    // the historical "enabled" token) -- the shipped meaning of the opt-in.
+    // ------------------------------------------------------------------
+
+    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
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 0eee60638..233679302 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
@@ -185,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;
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 3c257827a..7a39558c2 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);

From cf2db9a59006d8f8f5853504a8a0c007f8e3bb17 Mon Sep 17 00:00:00 2001
From: Eugene Lysiuchenko <79919431+eugenels@users.noreply.github.com>
Date: Fri, 11 Sep 2026 15:18:04 +0300
Subject: [PATCH 3/4] Expose the local-fsync watermark per table

Review found localDurableTableWatermarks written but never readable,
making the promised both-tier progress signal unreachable. The loop
now exposes getLocalDurableTableWatermark(tableName) alongside the
other ack counters.
---
 .../client/sf/cursor/CursorWebSocketSendLoop.java    | 12 ++++++++++++
 1 file changed, 12 insertions(+)

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 e01cbcfff..b13333fa8 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
@@ -1501,6 +1501,18 @@ 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

From ea04dc153299f45876cc772d13a9d45362190daf Mon Sep 17 00:00:00 2001
From: Eugene Lysiuchenko <79919431+eugenels@users.noreply.github.com>
Date: Fri, 11 Sep 2026 16:34:08 +0300
Subject: [PATCH 4/4] Cover durable-ack tiers with real tests

The tier feature landed with only mechanical test edits (false ->
DurableAckTiers.NONE signature updates) and four dead test hooks:
cursorSendLoopForTest, localDurableAck, getTotalLocalDurableAcks and
getLocalDurableTableWatermark had no callers. This commit adds the
missing behavioral coverage and puts every hook to use:

- DurableAckTiersTest covers parseConfigValue/configValue round trips,
  the request and confirmation wire tokens, the rejected spellings,
  and the isTrimOnLocalAck trim-trigger predicate.
- CursorWebSocketSendLoopDurableAckTest gains local-tier tests:
  local-only trim on STATUS_LOCAL_DURABLE_ACK, progress-only local
  acks with both tiers requested, monotonic local watermarks, the
  stray-frame warn path, watermark reset on reconnect, and the
  replicated-ack-in-local-mode subsumption path.
- WebSocketResponseLocalDurableAckTest round-trips the
  STATUS_LOCAL_DURABLE_ACK wire format through writeTo/readFrom,
  isStructurallyValid, truncation rejection, and status naming.
- TestWebSocketServer now captures the X-QWP-Request-Durable-Ack
  header and echoes the granted set (the legacy "true" request still
  confirms as "enabled"), with a fixed-grant override for mismatch
  tests. Integration tests drive local and local,replicated flows
  through the real handshake, pin the exact request token per
  configured set, assert denial when the server grants a different
  set, and pin the boolean connect overload's legacy mapping.
- DurableAckIntegrationTest.awaitOks counted one OK per row, but the
  server emits one OK per batch, so the helper always spun its full
  5s deadline and returned silently. awaitOkBatches counts batches
  and fails loudly on timeout; the suite drops from ~20s to under 1s.

Comment cleanup in the same area: the compat-overload block,
DurableAckTiers javadoc and Sender.requestDurableAck(boolean) now
describe what the code does instead of narrating the retyping and
release history ("was retyped", "shipped meaning").
---
 .../main/java/io/questdb/client/Sender.java   |   6 +-
 .../cutlass/http/client/WebSocketClient.java  |   6 +-
 .../cutlass/qwp/client/DurableAckTiers.java   |  25 +-
 .../qwp/client/QwpWebSocketSender.java        |  11 +-
 .../qwp/client/DurableAckTiersTest.java       | 148 ++++++++++
 .../WebSocketResponseLocalDurableAckTest.java | 109 ++++++++
 .../client/sf/DurableAckIntegrationTest.java  | 258 +++++++++++++++++-
 ...CursorWebSocketSendLoopDurableAckTest.java | 178 +++++++++++-
 .../qwp/websocket/TestWebSocketServer.java    |  45 ++-
 9 files changed, 742 insertions(+), 44 deletions(-)
 create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DurableAckTiersTest.java
 create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketResponseLocalDurableAckTest.java

diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java
index b474e1442..b996245c5 100644
--- a/core/src/main/java/io/questdb/client/Sender.java
+++ b/core/src/main/java/io/questdb/client/Sender.java
@@ -2782,9 +2782,9 @@ public LineSenderBuilder reconnectMaxDurationMillis(long millis) {
         /**
          * Opts the connection in for STATUS_DURABLE_ACK frames, using the
          * legacy "true" request token. Equivalent to
-         * {@code requestDurableAck("on")}: the shipped meaning is the
-         * replicated tier, so servers without primary replication deny the
-         * request and the sender fails at connect.
+         * {@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. * 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 b34eb76a2..81859c475 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 @@ -816,9 +816,9 @@ private static boolean extractDurableAckConfirmed(String response, String expect } String value = response.substring(valueStart, lineEnd).trim(); // The server echoes the granted set verbatim (or the - // historical "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. + // "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); } } 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 index e6259914d..b3648cbec 100644 --- 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 @@ -33,10 +33,11 @@ *

  • {@link #REPLICATED} — the server emits {@code STATUS_DURABLE_ACK} * frames once commits reach the object store (failover-safe).
  • * - * {@link #LEGACY_TRUE} is a modifier bit recording that the request travels - * as the shipped literal {@code "true"} header value, whose grant the server - * confirms with the historical {@code "enabled"} token. It always combines - * with {@link #REPLICATED} — the shipped meaning of the legacy opt-in. + * {@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 @@ -48,8 +49,8 @@ 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 legacy "true" - // request token and expect the historical "enabled" confirmation. + // 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() { @@ -78,8 +79,8 @@ public static String configValue(int tiers) { /** * 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 historical {@code "enabled"} token; explicit requests expect their - * own token set back verbatim. + * 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) { @@ -108,8 +109,8 @@ public static boolean isTrimOnLocalAck(int tiers) { /** * Parses a {@code request_durable_ack} value into a tier set, or -1 for - * an unrecognized value. {@code on} is the legacy alias for the - * replicated tier (its shipped meaning); {@code off} is {@link #NONE}. + * 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) { @@ -136,8 +137,8 @@ public static int parseConfigValue(CharSequence value) { /** * The X-QWP-Request-Durable-Ack header value for a tier set, or null when - * no tier is requested. A legacy set sends the shipped literal - * {@code "true"} so old servers keep recognizing it. + * 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) { 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 3e5a78d32..c94e94885 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 @@ -856,11 +856,12 @@ autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), } // ------------------------------------------------------------------ - // Binary-compatibility overloads. The durable-ack parameter was retyped - // from boolean to the DurableAckTiers bitmask; callers compiled against - // earlier releases still link against these boolean variants, which map - // true to the legacy "true" request (the replicated tier, confirmed by - // the historical "enabled" token) -- the shipped meaning of the opt-in. + // 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( 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 000000000..87b655653 --- /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/WebSocketResponseLocalDurableAckTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketResponseLocalDurableAckTest.java new file mode 100644 index 000000000..419a82392 --- /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 3378f7177..a50e5ffc5 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/CursorWebSocketSendLoopDurableAckTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckTest.java index b52ed76a8..dcf4eb40f 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 @@ -292,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 @@ -543,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; @@ -610,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 { @@ -664,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, DurableAckTiers.NONE); + 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, DurableAckTiers.REPLICATED); + 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/websocket/TestWebSocketServer.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java index 908e3bd56..8e6e35b9a 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) {