Skip to content

Commit aeb92b5

Browse files
glasstigerclaude
andcommitted
Set aside the slots recovery cannot rescue, instead of failing
The full-dictionary rebuild and the unreplayable-slot quarantine answer two halves of the same question and have to be ordered, not merged. Rebuilding the dictionary from the surviving frames' own delta sections rescues most of the slots that used to fail, so it must run FIRST -- a quarantine that pre-judged the slot on its own coverage arithmetic would set aside slots recovery can still replay in full, and strand data that drains perfectly. What it cannot rescue it still throws on, and that throw still escaped build(). senderId is stable and a not-fully-drained slot is retained on close, so every retry re-recovered the same slot and threw again: the application could not construct a Sender at all, and so could not even BUFFER new rows. One already-lost batch became an unbounded outage of everything after it, which inverts the guarantee store-and-forward exists to give. So seedGlobalDictionaryFromPersisted is now the single authority on whether a slot is replayable -- it is the only code that has tried every source of truth -- and its give-up is typed. build() waits for that verdict rather than forming its own, and on it sets the slot aside: the bytes are kept for forensics and resend, the .failed sentinel tells the orphan drainer the copy is human-in-the-loop, and the producer continues on a clean slot. The engine's own isRecoveredDictionaryIncomplete() guess goes away with it. Not one frame of a set-aside slot ever reaches the wire; that safety property is unchanged and still asserted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 521b48d commit aeb92b5

5 files changed

Lines changed: 214 additions & 59 deletions

File tree

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

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
4141
import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
4242
import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict;
43+
import io.questdb.client.cutlass.qwp.client.sf.cursor.UnreplayableSlotException;
4344
import io.questdb.client.impl.ConfStringParser;
4445
import io.questdb.client.impl.ConfigString;
4546
import io.questdb.client.impl.ConfigView;
@@ -1560,11 +1561,6 @@ public Sender build() {
15601561
CursorSendEngine cursorEngine = new CursorSendEngine(
15611562
slotPath, actualSfMaxBytes,
15621563
actualSfMaxTotalBytes, actualSfAppendDeadlineNanos);
1563-
if (cursorEngine.isRecoveredDictionaryIncomplete()) {
1564-
cursorEngine = quarantineTornSlot(
1565-
cursorEngine, sfDir, senderId, slotPath, actualSfMaxBytes,
1566-
actualSfMaxTotalBytes, actualSfAppendDeadlineNanos);
1567-
}
15681564
int actualErrorInboxCapacity = errorInboxCapacity != PARAMETER_NOT_SET_EXPLICITLY
15691565
? errorInboxCapacity
15701566
: io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher.DEFAULT_CAPACITY;
@@ -1576,7 +1572,15 @@ public Sender build() {
15761572
for (int i = 0, n = hosts.size(); i < n; i++) {
15771573
wsEndpoints.add(new QwpWebSocketSender.Endpoint(hosts.getQuick(i), ports.getQuick(i)));
15781574
}
1579-
QwpWebSocketSender connected;
1575+
// The recovery seed inside connect() is the authority on whether a recovered
1576+
// slot can be replayed: it rebuilds the dictionary from its intact prefix and
1577+
// then from the surviving frames' own delta sections, and throws
1578+
// UnreplayableSlotException only once neither source holds the missing ids.
1579+
// Quarantining on anything weaker would set aside slots that recovery can
1580+
// still rescue, so build() waits for that verdict rather than pre-judging it.
1581+
QwpWebSocketSender connected = null;
1582+
boolean quarantined = false;
1583+
while (connected == null) {
15801584
try {
15811585
connected = QwpWebSocketSender.connect(
15821586
wsEndpoints,
@@ -1603,6 +1607,32 @@ public Sender build() {
16031607
actualPoisonMinEscalationWindowMillis,
16041608
actualCatchUpCapGapMinEscalationWindowMillis
16051609
);
1610+
} catch (UnreplayableSlotException e) {
1611+
// The one failure build() recovers from. The slot's frames reference ids
1612+
// that nothing still holds, so they can never go on the wire -- but that is
1613+
// no reason to take the producer down with them. Before this, the throw
1614+
// escaped build() and, because senderId is stable and a not-fully-drained
1615+
// slot is retained on close, every retry re-recovered the same slot and
1616+
// threw again: the application could not construct a Sender at all, so it
1617+
// could not even BUFFER new rows. An already-lost batch became an unbounded
1618+
// outage of everything after it.
1619+
//
1620+
// Set the slot aside instead, keep its bytes for forensics and resend, and
1621+
// start the producer on a clean one. Once only: a second such failure would
1622+
// mean the FRESH slot is unreplayable, which cannot happen, so let it out
1623+
// rather than loop.
1624+
if (quarantined || slotPath == null) {
1625+
try {
1626+
cursorEngine.close();
1627+
} catch (Throwable ignored) {
1628+
// best-effort
1629+
}
1630+
throw e;
1631+
}
1632+
quarantined = true;
1633+
cursorEngine = quarantineTornSlot(
1634+
cursorEngine, e, sfDir, senderId, slotPath, actualSfMaxBytes,
1635+
actualSfMaxTotalBytes, actualSfAppendDeadlineNanos);
16061636
} catch (Throwable t) {
16071637
// connect() failed before ownership of cursorEngine
16081638
// transferred — close it ourselves.
@@ -1613,6 +1643,7 @@ public Sender build() {
16131643
}
16141644
throw t;
16151645
}
1646+
}
16161647
// connect() succeeded — `connected` now owns cursorEngine
16171648
// via setCursorEngine(engine, true). From here on, ANY
16181649
// failure must close `connected` (which closes the engine
@@ -3001,18 +3032,17 @@ private static long parseSizeValue(@NotNull StringSink value, @NotNull String na
30013032
* throw -- loudly, and never silently dropping bytes.
30023033
*/
30033034
private static CursorSendEngine quarantineTornSlot(
3004-
CursorSendEngine torn, String sfDir, String senderId, String slotPath,
3035+
CursorSendEngine torn, UnreplayableSlotException cause, String sfDir,
3036+
String senderId, String slotPath,
30053037
long sfMaxBytes, long sfMaxTotalBytes, long sfAppendDeadlineNanos
30063038
) {
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.
3039+
// The verdict, and the reason, come from the recovery seed -- the only code that
3040+
// has tried every source of truth. Recomputing them here would mean a second,
3041+
// independently-drifting notion of "unreplayable".
3042+
final String detail = cause.getMessage();
3043+
// Release the slot lock and the dictionary fd before renaming. connect()'s failure
3044+
// path already closed the engine; close() is idempotent, so make it explicit rather
3045+
// than depend on that.
30163046
torn.close();
30173047

30183048
String quarantinePath = null;

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import io.questdb.client.cutlass.qwp.client.sf.cursor.DefaultSenderErrorHandler;
4848
import io.questdb.client.cutlass.qwp.client.sf.cursor.DefaultSenderProgressHandler;
4949
import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict;
50+
import io.questdb.client.cutlass.qwp.client.sf.cursor.UnreplayableSlotException;
5051
import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderConnectionDispatcher;
5152
import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher;
5253
import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderProgressDispatcher;
@@ -3926,7 +3927,10 @@ private void seedGlobalDictionaryFromPersisted(PersistedSymbolDict pd) {
39263927
ObjList<String> fromFrames = new ObjList<>();
39273928
long coverage = cursorEngine.collectReplaySymbolsAbove(baseline, fromFrames);
39283929
if (coverage < 0) {
3929-
throw new LineSenderException(
3930+
// Typed, because Sender.build() sets such a slot aside instead of failing: this
3931+
// is the point at which every source of truth has been tried and none of them
3932+
// holds the missing ids. See UnreplayableSlotException.
3933+
throw new UnreplayableSlotException(
39303934
"recovered store-and-forward symbol dictionary is incomplete and cannot be "
39313935
+ "rebuilt from the surviving frames (likely a host crash tore its unsynced "
39323936
+ "tail): the frames reference symbol ids below their own delta start, which "

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -767,37 +767,6 @@ public boolean isDeltaDictEnabled() {
767767
return sfDir == null || persistedSymbolDict != null;
768768
}
769769

770-
/**
771-
* Whether this RECOVERED slot's surviving frames reference symbol ids the
772-
* recovered dictionary cannot cover -- i.e. the slot cannot be drained by a
773-
* producer that shares its id space, and a producer seeded from the short
774-
* dictionary would reuse ids those frames already define.
775-
* <p>
776-
* True in two situations, both of which mean the same thing to the caller:
777-
* <ul>
778-
* <li>the dictionary opened but is SHORT of the frames (a host/power crash
779-
* tore its unsynced tail -- see {@code PersistedSymbolDict}'s durability
780-
* note); or</li>
781-
* <li>the dictionary did not open at all ({@code persistedSymbolDict == null}
782-
* -- an unreadable or torn side-file, fd exhaustion, a read-only remount)
783-
* while the surviving frames are DELTA frames that need one. Coverage is
784-
* then zero, so any frame referencing an id at all is unreplayable.</li>
785-
* </ul>
786-
* Always false for a fresh (non-recovered) slot: {@code recoveredMaxSymbolId} is
787-
* -1 there, and -1 is below every coverage.
788-
* <p>
789-
* The caller must NOT drain such a slot with a live producer attached. The
790-
* foreground sender quarantines it and starts on a fresh slot
791-
* ({@code Sender.build()}); the orphan drainer reaches the same verdict
792-
* independently via the send loop's replay guard and marks the slot failed.
793-
* Either way the recorded bytes stay on disk and the affected data must be
794-
* resent -- but the producer keeps producing, which is the whole point of
795-
* store-and-forward.
796-
*/
797-
public boolean isRecoveredDictionaryIncomplete() {
798-
long coverage = persistedSymbolDict != null ? persistedSymbolDict.size() : 0;
799-
return recoveredMaxSymbolId >= coverage;
800-
}
801770

802771
/**
803772
* Pass-through to {@link SegmentRing#nextSealedAfter(MmapSegment)}.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*******************************************************************************
2+
* ___ _ ____ ____
3+
* / _ \ _ _ ___ ___| |_| _ \| __ )
4+
* | | | | | | |/ _ \/ __| __| | | | _ \
5+
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
6+
* \__\_\\__,_|\___||___/\__|____/|____/
7+
*
8+
* Copyright (c) 2014-2019 Appsicle
9+
* Copyright (c) 2019-2026 QuestDB
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*
23+
******************************************************************************/
24+
25+
package io.questdb.client.cutlass.qwp.client.sf.cursor;
26+
27+
import io.questdb.client.cutlass.line.LineSenderException;
28+
29+
/**
30+
* A recovered store-and-forward slot whose symbol dictionary cannot be rebuilt at all --
31+
* neither from the persisted side-file's intact prefix nor from the surviving frames' own
32+
* delta sections. Its frames reference ids that nothing still holds, so replaying them
33+
* would null-pad the gap on the server and silently misattribute symbol values.
34+
* <p>
35+
* This is the ONE verdict {@code seedGlobalDictionaryFromPersisted} reaches after it has
36+
* tried every source of truth it has, so it is the only signal that may quarantine a slot.
37+
* A distinct type, rather than a message match, because the difference between "this slot
38+
* is unreplayable" and any other {@link LineSenderException} out of the connect path is the
39+
* difference between setting a slot aside and silently discarding one that was fine.
40+
* <p>
41+
* It stays a {@code LineSenderException} so that a caller which does NOT handle it -- the
42+
* background drainer, a test constructing the sender directly -- keeps the old fail-clean
43+
* behaviour rather than seeing a new checked type. Only {@code Sender.build()} treats it as
44+
* recoverable, by setting the slot aside and starting the producer on a fresh one.
45+
*/
46+
public class UnreplayableSlotException extends LineSenderException {
47+
48+
public UnreplayableSlotException(CharSequence message) {
49+
super(message);
50+
}
51+
}

0 commit comments

Comments
 (0)