3838import io .questdb .client .cutlass .qwp .client .QwpWebSocketSender ;
3939import io .questdb .client .cutlass .qwp .client .sf .cursor .CursorSendEngine ;
4040import 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 ;
4143import io .questdb .client .impl .ConfStringParser ;
4244import io .questdb .client .impl .ConfigString ;
4345import 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 );
0 commit comments