Skip to content

Commit 017652f

Browse files
glasstigerclaude
andcommitted
Keep the producer alive through a torn symbol dictionary
Four fixes to the QWP delta symbol-dictionary work, all on the store-and-forward recovery and failover paths. Set an unreplayable slot aside instead of bricking the sender. A host crash can tear the unsynced .symbol-dict relative to the frames it serves, and the resulting frames genuinely cannot be replayed. Detecting that is right; throwing out of build() was not. senderId defaults to a stable name, a not-fully-drained slot is retained on close, and so every subsequent build() re-recovered the same slot and threw again -- forever, until an operator removed the directory by hand. The application could not construct a Sender at all, so it could not even buffer new rows: one already-lost batch became an unbounded outage of everything after it. build() now renames the slot aside, marks it .failed for the orphan drainer, and continues on a fresh empty slot. The bytes are kept for resend; not one of its frames ever reaches the wire. Never recreate a dictionary over an existing file. open() fell through to openFresh() -- which is O_TRUNC -- on any read or parse failure, so a single transient EIO, or a rollback to a client that does not know a newer version byte, destroyed the only copy of load-bearing state and made every surviving delta frame permanently unreplayable. It now degrades to null, leaving the file intact: the sender falls back to full self-sufficient frames and a later attempt can still recover the slot in full. Give the catch-up cap-gap terminal a wall-clock dwell. The settle budget was attempt-based, and 16 attempts at the capped reconnect backoff burn through in about two minutes -- less than an ordinary rolling restart of the larger-cap node, which is the very transient the budget exists to ride out. Escalation now needs both the strike count and a minimum dwell, anchored at the first cap gap of the episode, defaulting to five minutes and tunable through catchup_cap_gap_min_escalation_window_millis. Checksum the persisted dictionary per chunk, not per entry. Crc32c is a native call, so a per-entry checksum put one JNI transition, one sub-cache-line copy and one redundant varint decode on the producer thread for every new symbol -- a thousand native calls per flush on the one-new-symbol-per-row batch this feature exists to serve. A chunk is exactly the set of symbols one frame introduces, and every frame's deltaStart is a chunk boundary, so per-entry granularity bought no extra recoverable prefix. Recovery also folds its two passes into one. ensureScratch now throws instead of clamping below the requested size, which would have turned a clean out-of-memory into a native-heap overflow on the dictionary write path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c181e4f commit 017652f

10 files changed

Lines changed: 974 additions & 357 deletions

File tree

core/src/main/java/io/questdb/client/Sender.java

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
3939
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
4040
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
41+
import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
42+
import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict;
4143
import io.questdb.client.impl.ConfStringParser;
4244
import io.questdb.client.impl.ConfigString;
4345
import io.questdb.client.impl.ConfigView;
@@ -988,6 +990,12 @@ final class LineSenderBuilder {
988990
// build() time. 0 or negative is a documented "disable" value, so
989991
// a Long.MIN_VALUE sentinel keeps it distinguishable from "unset".
990992
private static final long DURABLE_ACK_KEEPALIVE_NOT_SET = Long.MIN_VALUE;
993+
private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(LineSenderBuilder.class);
994+
// How many quarantined copies of one slot may pile up under sf_dir before build()
995+
// refuses to set aside another. Each is an unreplayable slot a human still has to
996+
// look at; accumulating them without bound would turn a disk-space problem into a
997+
// second incident.
998+
private static final int MAX_QUARANTINE_SLOT_ATTEMPTS = 64;
991999
private static final int MIN_BUFFER_SIZE = AuthUtils.CHALLENGE_LEN + 1; // challenge size + 1;
9921000
// sf-client.md section 4.4: the inbox capacity must accommodate the
9931001
// distinct error categories in a bursty error stream so that drop-oldest
@@ -1003,6 +1011,10 @@ final class LineSenderBuilder {
10031011
private static final int PROTOCOL_TCP = 0;
10041012
private static final int PROTOCOL_UDP = 3;
10051013
private static final int PROTOCOL_WEBSOCKET = 2;
1014+
// Suffix for a slot set aside by quarantineTornSlot. Deliberately NOT the
1015+
// sender's own slot name, so OrphanScanner sees the quarantined copy and the
1016+
// orphan drainer can still deliver it if its frames turn out to be replayable.
1017+
private static final String QUARANTINE_SLOT_SUFFIX = ".unreplayable-";
10061018
private final ObjList<String> hosts = new ObjList<>();
10071019
private final IntList ports = new IntList();
10081020
private long authTimeoutMillis = QwpWebSocketSender.DEFAULT_AUTH_TIMEOUT_MS;
@@ -1051,6 +1063,7 @@ final class LineSenderBuilder {
10511063
private int errorInboxCapacity = PARAMETER_NOT_SET_EXPLICITLY;
10521064
private int maxFrameRejections = PARAMETER_NOT_SET_EXPLICITLY;
10531065
private long poisonMinEscalationWindowMillis = PARAMETER_NOT_SET_EXPLICITLY;
1066+
private long catchUpCapGapMinEscalationWindowMillis = PARAMETER_NOT_SET_EXPLICITLY;
10541067
private String httpPath;
10551068
private String httpSettingsPath;
10561069
private int httpTimeout = PARAMETER_NOT_SET_EXPLICITLY;
@@ -1505,6 +1518,10 @@ public Sender build() {
15051518
long actualPoisonMinEscalationWindowMillis = poisonMinEscalationWindowMillis != PARAMETER_NOT_SET_EXPLICITLY
15061519
? poisonMinEscalationWindowMillis
15071520
: CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS;
1521+
long actualCatchUpCapGapMinEscalationWindowMillis =
1522+
catchUpCapGapMinEscalationWindowMillis != PARAMETER_NOT_SET_EXPLICITLY
1523+
? catchUpCapGapMinEscalationWindowMillis
1524+
: CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS;
15081525

15091526
// sfDir is the parent (group root); the actual slot lives
15101527
// under sfDir/senderId. This is what the engine sees — the
@@ -1543,6 +1560,11 @@ public Sender build() {
15431560
CursorSendEngine cursorEngine = new CursorSendEngine(
15441561
slotPath, actualSfMaxBytes,
15451562
actualSfMaxTotalBytes, actualSfAppendDeadlineNanos);
1563+
if (cursorEngine.isRecoveredDictionaryIncomplete()) {
1564+
cursorEngine = quarantineTornSlot(
1565+
cursorEngine, sfDir, senderId, slotPath, actualSfMaxBytes,
1566+
actualSfMaxTotalBytes, actualSfAppendDeadlineNanos);
1567+
}
15461568
int actualErrorInboxCapacity = errorInboxCapacity != PARAMETER_NOT_SET_EXPLICITLY
15471569
? errorInboxCapacity
15481570
: io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher.DEFAULT_CAPACITY;
@@ -1578,7 +1600,8 @@ public Sender build() {
15781600
connectionListener,
15791601
actualConnectionListenerInboxCapacity,
15801602
actualMaxFrameRejections,
1581-
actualPoisonMinEscalationWindowMillis
1603+
actualPoisonMinEscalationWindowMillis,
1604+
actualCatchUpCapGapMinEscalationWindowMillis
15821605
);
15831606
} catch (Throwable t) {
15841607
// connect() failed before ownership of cursorEngine
@@ -1720,6 +1743,37 @@ public Sender build() {
17201743
* <p>
17211744
* WebSocket transport only.
17221745
*/
1746+
/**
1747+
* Minimum wall-clock time (millis) a symbol-dictionary catch-up CAP GAP must
1748+
* persist before the sender gives up on it and fails.
1749+
* <p>
1750+
* A cap gap means a symbol already accepted by one node is too large to
1751+
* re-register on the node the sender just failed over to, because that node
1752+
* advertises a smaller maximum batch size. On a homogeneous cluster this cannot
1753+
* happen; it takes a heterogeneous or mid-roll cluster, or an operator lowering
1754+
* the cap below existing data.
1755+
* <p>
1756+
* The sender retries such a gap across reconnects rather than failing on sight,
1757+
* because the larger-cap node may simply be away -- a rolling restart is the most
1758+
* likely reason a failover happened at all. It gives up only once the gap has BOTH
1759+
* recurred many times AND persisted for this long, so an ordinary cluster
1760+
* operation cannot bring down a live producer. Raise it for a cluster whose
1761+
* rolling restarts take longer than the 5-minute default; set it to {@code 0} to
1762+
* fail as soon as the retry count is exhausted.
1763+
* <p>
1764+
* WebSocket transport only.
1765+
*/
1766+
public LineSenderBuilder catchUpCapGapMinEscalationWindowMillis(long millis) {
1767+
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
1768+
throw new LineSenderException("catchup_cap_gap_min_escalation_window_millis is only supported for WebSocket transport");
1769+
}
1770+
if (millis < 0) {
1771+
throw new LineSenderException("catchup_cap_gap_min_escalation_window_millis must be >= 0: ").put(millis);
1772+
}
1773+
this.catchUpCapGapMinEscalationWindowMillis = millis;
1774+
return this;
1775+
}
1776+
17231777
public LineSenderBuilder closeFlushTimeoutMillis(long timeoutMillis) {
17241778
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
17251779
throw new LineSenderException("close_flush_timeout_millis is only supported for WebSocket transport");
@@ -2916,6 +2970,77 @@ private static long parseSizeValue(@NotNull StringSink value, @NotNull String na
29162970
}
29172971
}
29182972

2973+
/**
2974+
* Sets a recovered slot whose symbol dictionary cannot cover its surviving frames
2975+
* aside, and returns a fresh engine on an empty slot so the producer can keep
2976+
* producing.
2977+
* <p>
2978+
* Such a slot is unreplayable BY THIS PRODUCER: its frames reference symbol ids the
2979+
* recovered dictionary lost (a host/power crash tore the unsynced side-file), so a
2980+
* producer seeded from the short dictionary would hand those ids to different
2981+
* symbols and silently misattribute values. Detecting that is correct and
2982+
* load-bearing -- but simply THROWING is not a safe response. {@code senderId}
2983+
* defaults to a stable name, so a restarted process re-adopts the same slot; the
2984+
* engine's close retains a slot that is not fully drained; and so every subsequent
2985+
* {@code build()} would re-recover the same slot and throw again -- forever, until
2986+
* an operator deleted the directory by hand. The application could not construct a
2987+
* Sender at all, and so could not even BUFFER new rows. That trades a bounded,
2988+
* already-lost batch for an unbounded outage of everything after it, which inverts
2989+
* the one guarantee store-and-forward exists to give.
2990+
* <p>
2991+
* So: rename the slot aside instead. The bytes are preserved for forensics and for
2992+
* the orphan drainer, which reaches the same verdict independently (its send loop's
2993+
* replay guard fires and it marks the slot {@code .failed}) -- and which, on a slot
2994+
* that turns out to be drainable after all (frames written in full-dictionary
2995+
* fallback mode are self-sufficient), simply drains it. The new name is NOT the
2996+
* sender's own slot name, so {@code OrphanScanner} will consider it. The producer,
2997+
* meanwhile, starts on a clean empty slot and never notices.
2998+
* <p>
2999+
* If the rename fails (a Windows share lock, a read-only mount) there is no way to
3000+
* free the slot name without destroying data, so fall back to the old behaviour and
3001+
* throw -- loudly, and never silently dropping bytes.
3002+
*/
3003+
private static CursorSendEngine quarantineTornSlot(
3004+
CursorSendEngine torn, String sfDir, String senderId, String slotPath,
3005+
long sfMaxBytes, long sfMaxTotalBytes, long sfAppendDeadlineNanos
3006+
) {
3007+
long recoveredMaxSymbolId = torn.recoveredMaxSymbolId();
3008+
PersistedSymbolDict dict = torn.getPersistedSymbolDict();
3009+
int coverage = dict != null ? dict.size() : 0;
3010+
String detail = "recovered store-and-forward symbol dictionary cannot cover the surviving "
3011+
+ "frames (likely a host crash tore its unsynced tail): frames reference symbol id "
3012+
+ recoveredMaxSymbolId + " but the recovered dictionary holds only " + coverage
3013+
+ " id(s)";
3014+
// Release the slot lock and the dictionary fd before renaming. The slot is not
3015+
// fully drained, so close() retains every file.
3016+
torn.close();
3017+
3018+
String quarantinePath = null;
3019+
for (int i = 0; i < MAX_QUARANTINE_SLOT_ATTEMPTS; i++) {
3020+
String candidate = sfDir + "/" + senderId + QUARANTINE_SLOT_SUFFIX + i;
3021+
if (!Files.exists(candidate)) {
3022+
quarantinePath = candidate;
3023+
break;
3024+
}
3025+
}
3026+
if (quarantinePath == null || Files.rename(slotPath, quarantinePath) != 0) {
3027+
throw new LineSenderException(
3028+
detail + "; the affected data must be resent. The slot could not be set aside "
3029+
+ "automatically (" + (quarantinePath == null
3030+
? "too many quarantined slots already under " + sfDir
3031+
: "rename to " + quarantinePath + " failed")
3032+
+ "), so this sender cannot start until "
3033+
+ slotPath + " is moved or removed by hand");
3034+
}
3035+
// Mark the quarantined copy so the orphan drainer treats it as a
3036+
// human-in-the-loop slot rather than silently retrying it forever.
3037+
OrphanScanner.markFailed(quarantinePath, detail);
3038+
LOG.error("{} -- the slot has been set aside at {} and the affected data must be resent; "
3039+
+ "this sender continues on a fresh, empty slot at {}",
3040+
detail, quarantinePath, slotPath);
3041+
return new CursorSendEngine(slotPath, sfMaxBytes, sfMaxTotalBytes, sfAppendDeadlineNanos);
3042+
}
3043+
29193044
private static int resolveIPv4(String host) {
29203045
try {
29213046
byte[] addr = InetAddress.getByName(host).getAddress();
@@ -3413,6 +3538,12 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
34133538
}
34143539
pos = getValue(configurationString, pos, sink, "poison_min_escalation_window_millis");
34153540
poisonMinEscalationWindowMillis(parseLongValue(sink, "poison_min_escalation_window_millis"));
3541+
} else if (Chars.equals("catchup_cap_gap_min_escalation_window_millis", sink)) {
3542+
if (protocol != PROTOCOL_WEBSOCKET) {
3543+
throw new LineSenderException("catchup_cap_gap_min_escalation_window_millis is only supported for WebSocket transport");
3544+
}
3545+
pos = getValue(configurationString, pos, sink, "catchup_cap_gap_min_escalation_window_millis");
3546+
catchUpCapGapMinEscalationWindowMillis(parseLongValue(sink, "catchup_cap_gap_min_escalation_window_millis"));
34163547
} else if (Chars.equals("initial_connect_retry", sink)) {
34173548
if (protocol != PROTOCOL_WEBSOCKET) {
34183549
throw new LineSenderException("initial_connect_retry is only supported for WebSocket transport");
@@ -3686,6 +3817,9 @@ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString)
36863817
if (view.has("poison_min_escalation_window_millis")) {
36873818
poisonMinEscalationWindowMillis(wsLong(view, v, "poison_min_escalation_window_millis"));
36883819
}
3820+
if (view.has("catchup_cap_gap_min_escalation_window_millis")) {
3821+
catchUpCapGapMinEscalationWindowMillis(wsLong(view, v, "catchup_cap_gap_min_escalation_window_millis"));
3822+
}
36893823
if (view.has("sf_append_deadline_millis")) {
36903824
sfAppendDeadlineMillis(wsLong(view, v, "sf_append_deadline_millis"));
36913825
}
@@ -3862,6 +3996,7 @@ public java.util.Map<String, Object> wsConfigSnapshotForTest() {
38623996
m.put("max_background_drainers", maxBackgroundDrainers);
38633997
m.put("max_frame_rejections", maxFrameRejections);
38643998
m.put("poison_min_escalation_window_millis", poisonMinEscalationWindowMillis);
3999+
m.put("catchup_cap_gap_min_escalation_window_millis", catchUpCapGapMinEscalationWindowMillis);
38654000
m.put("error_inbox_capacity", errorInboxCapacity);
38664001
m.put("connection_listener_inbox_capacity", connectionListenerInboxCapacity);
38674002
m.put("token", httpToken);

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,12 @@ public class QwpWebSocketSender implements Sender {
313313
// maxFrameRejections (connect-string key poison_min_escalation_window_millis).
314314
private long poisonMinEscalationWindowMillis =
315315
CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS;
316+
// Minimum wall-clock dwell a symbol-dict catch-up cap gap must persist before the
317+
// send loop latches a terminal (connect-string key
318+
// catchup_cap_gap_min_escalation_window_millis). See
319+
// CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS.
320+
private long catchUpCapGapMinEscalationWindowMillis =
321+
CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS;
316322
private long reconnectInitialBackoffMillis =
317323
CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS;
318324
private long reconnectMaxBackoffMillis =
@@ -691,7 +697,8 @@ public static QwpWebSocketSender connect(
691697
durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs,
692698
connectionListener, connectionListenerInboxCapacity,
693699
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
694-
CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS);
700+
CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS,
701+
CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS);
695702
}
696703

697704
/**
@@ -722,7 +729,8 @@ public static QwpWebSocketSender connect(
722729
SenderConnectionListener connectionListener,
723730
int connectionListenerInboxCapacity,
724731
int maxFrameRejections,
725-
long poisonMinEscalationWindowMillis
732+
long poisonMinEscalationWindowMillis,
733+
long catchUpCapGapMinEscalationWindowMillis
726734
) {
727735
QwpWebSocketSender sender = new QwpWebSocketSender(
728736
endpoints, tlsConfig,
@@ -740,6 +748,7 @@ public static QwpWebSocketSender connect(
740748
sender.durableAckKeepaliveIntervalMillis = durableAckKeepaliveIntervalMillis;
741749
sender.maxFrameRejections = maxFrameRejections;
742750
sender.poisonMinEscalationWindowMillis = poisonMinEscalationWindowMillis;
751+
sender.catchUpCapGapMinEscalationWindowMillis = catchUpCapGapMinEscalationWindowMillis;
743752
sender.initialConnectMode = initialConnectMode == null
744753
? Sender.InitialConnectMode.OFF
745754
: initialConnectMode;
@@ -2414,7 +2423,8 @@ public synchronized void startOrphanDrainers(
24142423
requestDurableAck,
24152424
durableAckKeepaliveIntervalMillis,
24162425
maxFrameRejections,
2417-
poisonMinEscalationWindowMillis);
2426+
poisonMinEscalationWindowMillis,
2427+
catchUpCapGapMinEscalationWindowMillis);
24182428
ref[0] = drainer;
24192429
drainerPool.submit(drainer);
24202430
}
@@ -3342,7 +3352,8 @@ private void ensureConnected() {
33423352
requestDurableAck,
33433353
durableAckKeepaliveIntervalMillis,
33443354
maxFrameRejections,
3345-
poisonMinEscalationWindowMillis);
3355+
poisonMinEscalationWindowMillis,
3356+
catchUpCapGapMinEscalationWindowMillis);
33463357
// Plug the async-delivery sink before start() so the I/O thread
33473358
// never observes a null dispatcher between recordFatal and
33483359
// notification — the test for null in dispatchError handles

0 commit comments

Comments
 (0)