Skip to content

Commit 0fe4873

Browse files
glasstigerclaude
andcommitted
Close four data-integrity holes in the delta symbol dictionary
C1. CursorSendEngine.close() unlinked the parent-anchored logical slot lock while Sender.build() still held it. A fresh slot is "fully drained" by definition (publishedFsn() < 0), and build() closes the engine from inside its acquireLogical scope whenever connect fails -- the ordinary "server isn't up yet" startup. On POSIX the unlink frees the pathname without releasing the flock, so the next acquireLogical creates a second inode and locks it successfully: two parties owning the lock that exists solely to serialise the quarantine close->rename->recreate window. close(boolean) lets a caller that holds the lock skip the reclaim; build() and quarantineTornSlot pass false. An unheld lock is still reclaimed, so .slot-locks does not grow without bound. C2. Recovery rebuilds the producer dictionary from the side-file's intact prefix AND the surviving frames' own deltas, then resumes sentMaxSymbolId at the combined tip -- above pd.size(). Every frame published afterwards then carried a deltaStart the side-file could not describe. The steady-state write-ahead did not close that gap: it persists [pd.size() .. currentBatchMaxSymbolId] and returns early when the batch's highest id is below pd.size(), so it healed only if, and only as far as, later traffic referenced the recovered high ids. The frames carrying those ids are the oldest unacked and so the first to be acked and trimmed; once gone, an ordinary process crash -- which store-and-forward promises to survive -- left a slot whose frames reference ids nothing holds, and build() quarantined it. Recovery now heals the side-file eagerly and in full, before any new frame publishes. That heal advances pd.size() before the send loop is built, and the loop seeded sentDictCount from the live size() while taking its bytes from the fixed loaded region -- so the mirror would have claimed symbols it did not hold. PersistedSymbolDict now exposes recoveredSize(), the entry count that matches loadedEntriesAddr/Len, and the loop and the loaded- region decoder both key off that instead. C3. deltaDictEnabled was written once at setCursorEngine with no runtime disable, so a mid-run .symbol-dict write failure killed flush() forever. A full disk reaches exactly that state: SF's segments are pre-allocated mmap files and stay writable while the growing side-file does not. Full self-sufficient frames need no side file at all, so a persist failure now degrades to them. The failing flush still throws -- beginMessage has already baked a delta deltaStart into the staged frame -- but the throw precedes every publish, so the rows stay buffered and the next flush re-encodes them from id 0. C4. Two recovery-seed exits threw raw IllegalStateException, which Sender.build() does not route to its quarantine handler: with a stable senderId every restart re-recovered the same slot and rethrew, so the application could never construct a Sender and could not even buffer. Both now throw UnreplayableSlotException. QwpWebSocketSender.connect's rollback also let a close() failure replace the original exception and demote a recoverable slot back to that brick; it now addSuppressed. Regression tests, each verified to fail with its fix reverted: EngineCloseSlotLockReleaseTest#testCloseDoesNotUnlinkALogicalLockItsCallerHolds ("close(false) must leave the logical lock file alone") and DeltaDictRecoveryTest#testRecoveryHealsThePersistedDictionaryBeforeAnyNewFrame ("expected:<3> but was:<2>"). A companion test pins that an unheld logical lock is still reclaimed. Full client suite: 2687 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d76ef16 commit 0fe4873

7 files changed

Lines changed: 348 additions & 44 deletions

File tree

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1643,7 +1643,8 @@ public Sender build() {
16431643
// rather than loop.
16441644
if (quarantined || slotPath == null) {
16451645
try {
1646-
cursorEngine.close();
1646+
// close(false): we still hold the logical slot lock.
1647+
cursorEngine.close(false);
16471648
} catch (Throwable ignored) {
16481649
// best-effort
16491650
}
@@ -1655,9 +1656,12 @@ public Sender build() {
16551656
actualSfMaxTotalBytes, actualSfAppendDeadlineNanos);
16561657
} catch (Throwable t) {
16571658
// connect() failed before ownership of cursorEngine
1658-
// transferred — close it ourselves.
1659+
// transferred — close it ourselves. close(false)
1660+
// because logicalSlotLock is still held here: a fresh
1661+
// slot is fully drained, so the default close would
1662+
// unlink the very lock file this scope holds.
16591663
try {
1660-
cursorEngine.close();
1664+
cursorEngine.close(false);
16611665
} catch (Throwable ignored) {
16621666
// best-effort
16631667
}
@@ -3064,8 +3068,10 @@ private static CursorSendEngine quarantineTornSlot(
30643068
final String detail = cause.getMessage();
30653069
// Release the slot lock and the dictionary fd before renaming. connect()'s failure
30663070
// path already closed the engine; close() is idempotent, so make it explicit rather
3067-
// than depend on that.
3068-
torn.close();
3071+
// than depend on that. close(false): build() holds the logical slot lock across this
3072+
// whole transition -- that is precisely what serialises the rename against a queued
3073+
// orphan drainer -- so the engine must not unlink it.
3074+
torn.close(false);
30693075
Runnable hook = quarantineAfterCloseHook;
30703076
if (hook != null) {
30713077
hook.run();

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

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,16 @@ public static QwpWebSocketSender connect(
804804
}
805805
sender.ensureConnected();
806806
} catch (Throwable t) {
807-
sender.close();
807+
// Preserve t's IDENTITY through the rollback. Sender.build() routes on the
808+
// exception type -- only UnreplayableSlotException reaches its quarantine
809+
// handler -- and close() accumulates cleanup errors and ends in
810+
// rethrowTerminal, so letting a close failure propagate here would REPLACE t
811+
// and silently demote a recoverable slot back to the permanent build() brick.
812+
try {
813+
sender.close();
814+
} catch (Throwable closeFailure) {
815+
t.addSuppressed(closeFailure);
816+
}
808817
throw t;
809818
}
810819
return sender;
@@ -3864,6 +3873,84 @@ private void advanceSentMaxSymbolId() {
38643873
}
38653874
}
38663875

3876+
/**
3877+
* Stops emitting delta dictionaries for the rest of this sender's life, after the
3878+
* per-slot {@code .symbol-dict} has proved unwritable.
3879+
* <p>
3880+
* The side-file can stop accepting appends mid-run -- a full disk or an exhausted
3881+
* quota, where SF's own segments stay writable because they are pre-allocated mmap
3882+
* files while the dictionary is the one thing still growing. Without a way back,
3883+
* {@code deltaDictEnabled} is written once at {@code setCursorEngine} and every
3884+
* later {@code flush()} re-throws forever: a condition store-and-forward is built
3885+
* to survive becomes total, permanent ingestion loss.
3886+
* <p>
3887+
* Full self-sufficient frames need no side file at all -- each carries the whole
3888+
* dictionary from id 0, which is exactly what recovery and orphan-drain replay
3889+
* against a fresh server. So degrade instead of dying. The producer's monotonic
3890+
* baseline stops being consulted ({@link #symbolDeltaBaseline()} returns -1), and
3891+
* the write-ahead persist becomes a no-op.
3892+
* <p>
3893+
* Producer-thread only, like every other reader of {@code deltaDictEnabled}.
3894+
*/
3895+
private void disableDeltaDict(Throwable cause) {
3896+
if (!deltaDictEnabled) {
3897+
return;
3898+
}
3899+
deltaDictEnabled = false;
3900+
LOG.warn("symbol dictionary persistence failed; this sender has switched to full "
3901+
+ "self-sufficient frames for the rest of its life (bandwidth cost only -- "
3902+
+ "no data is at risk, and recovery replays such frames without a side file)",
3903+
cause);
3904+
}
3905+
3906+
/**
3907+
* Writes the ids the surviving frames contributed above the persisted prefix back
3908+
* into {@code .symbol-dict}, immediately, before any new frame can be published.
3909+
* <p>
3910+
* {@link #seedGlobalDictionaryFromPersisted} can rebuild the producer dictionary from
3911+
* TWO sources -- the side-file's intact prefix and the surviving frames' own delta
3912+
* sections -- and then resumes {@code sentMaxSymbolId} at the combined tip. When the
3913+
* frames contributed anything, that tip is ABOVE {@code pd.size()}, so every frame
3914+
* published from here on carries a {@code deltaStart} the side-file cannot describe.
3915+
* That breaks the write-ahead invariant the whole design rests on: the persisted
3916+
* dictionary must be a superset of every recoverable frame's references.
3917+
* <p>
3918+
* The steady-state write-ahead does NOT close that gap on its own. It persists
3919+
* {@code [pd.size() .. currentBatchMaxSymbolId]} and returns early when the batch's
3920+
* highest id is below {@code pd.size()}, so it heals only if -- and only as far as --
3921+
* a later batch happens to reference the recovered high ids. Meanwhile the frames
3922+
* that carry those ids are the oldest unacked, so they are the FIRST to be acked and
3923+
* trimmed. Once they are gone, an ordinary process crash (which store-and-forward
3924+
* promises to survive; only the original tear needs a host crash) leaves a slot whose
3925+
* frames reference ids nothing holds: recovery marks a gap and {@code build()}
3926+
* quarantines it with "resend the affected data".
3927+
* <p>
3928+
* Healing here, eagerly and in full, restores the invariant before the window opens.
3929+
*/
3930+
private void healPersistedDictionary(PersistedSymbolDict pd) {
3931+
if (pd == null || !deltaDictEnabled) {
3932+
return;
3933+
}
3934+
int from = pd.size();
3935+
int to = globalSymbolDictionary.size() - 1;
3936+
if (to < from) {
3937+
return; // the side-file already covers everything the frames defined
3938+
}
3939+
try {
3940+
pd.appendSymbols(globalSymbolDictionary, from, to);
3941+
} catch (Throwable t) {
3942+
if (t instanceof Error) {
3943+
throw (Error) t;
3944+
}
3945+
// Do NOT fail recovery: the surviving frames still carry these ids in their
3946+
// own deltas, so THIS session replays correctly either way. Only a future
3947+
// recovery, after those frames are trimmed, would be affected -- and the
3948+
// degrade below removes even that exposure by dropping back to frames that
3949+
// need no side file.
3950+
disableDeltaDict(t);
3951+
}
3952+
}
3953+
38673954
/**
38683955
* Appends the symbols this frame introduces ({@code [sentMaxSymbolId+1 ..
38693956
* currentBatchMaxSymbolId]}) to the slot's persisted dictionary BEFORE the
@@ -3932,7 +4019,16 @@ private void persistNewSymbolsBeforePublish() {
39324019
if (t instanceof Error) {
39334020
throw (Error) t;
39344021
}
3935-
throw new LineSenderException("failed to persist symbol dictionary before publish", t);
4022+
// Degrade before throwing, so this failure is survivable rather than terminal:
4023+
// every LATER flush emits full self-sufficient frames, which need no side file
4024+
// (see disableDeltaDict). This one flush still has to fail -- beginMessage has
4025+
// already baked a delta deltaStart into the staged frame, and publishing it
4026+
// would put ids on the ring that the side-file cannot describe. The throw
4027+
// precedes every publish, so the caller's rows stay buffered and the next
4028+
// flush() re-encodes them from id 0.
4029+
disableDeltaDict(t);
4030+
throw new LineSenderException("failed to persist symbol dictionary before publish; "
4031+
+ "this sender has switched to full self-sufficient frames -- retry the flush", t);
39364032
}
39374033
}
39384034

@@ -4054,6 +4150,10 @@ private void seedGlobalDictionaryFromPersisted(PersistedSymbolDict pd) {
40544150
// sentDictCount once those frames have gone out. The first new frame therefore
40554151
// starts its delta exactly at the tip, and the replay guard passes.
40564152
sentMaxSymbolId = globalSymbolDictionary.size() - 1;
4153+
// ...but the baseline now sits ABOVE pd.size() whenever the frames contributed
4154+
// ids, so restore the write-ahead invariant right now rather than hoping a later
4155+
// batch reaches high enough to do it. See healPersistedDictionary.
4156+
healPersistedDictionary(pd);
40574157
}
40584158

40594159
/**

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

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -409,14 +409,13 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
409409
// baseline 0 once pd is gone, and checkedRecoveryAnalysis rejects a
410410
// baseline that disagrees with the fold. Discarding a dictionary that
411411
// held entries (size > 0) therefore desynchronised the two and threw
412-
// IllegalStateException("recovery symbol baseline mismatch") out of
413-
// build(). That is NOT an UnreplayableSlotException, so build()'s
414-
// quarantine handler could not catch it and set the slot aside: with a
415-
// stable senderId every restart re-recovered the same slot and threw
416-
// again, so the application could never construct a Sender -- it could
417-
// not even BUFFER new rows. Exactly the outage quarantineTornSlot
418-
// exists to prevent, on a slot that is fully recoverable (its frames
419-
// carry their whole dictionary inline).
412+
// "recovery symbol baseline mismatch" out of build(). checkedRecoveryAnalysis
413+
// now raises that as an UnreplayableSlotException, so build() would at least
414+
// set the slot aside rather than rethrow forever -- but quarantining is
415+
// still the wrong outcome HERE, because this slot is fully recoverable
416+
// (its frames carry their whole dictionary inline). Re-folding avoids the
417+
// mismatch entirely instead of trading a permanent brick for a needless
418+
// quarantine plus a "resend the affected data" the operator does not owe.
420419
//
421420
// Reachable on one transient plus one crash: a session whose
422421
// .symbol-dict fails to open (EIO, fd exhaustion, a Windows share
@@ -640,7 +639,24 @@ public long appendOrFsn(long payloadAddr, int payloadLen, long spinDeadlineNanos
640639
}
641640

642641
@Override
643-
public synchronized void close() {
642+
public void close() {
643+
close(true);
644+
}
645+
646+
/**
647+
* As {@link #close()}, but {@code reclaimLogicalSlotLock == false} skips the
648+
* parent-anchored logical slot-lock unlink at fully-drained retirement.
649+
* <p>
650+
* A caller that HOLDS that lock must pass {@code false}. {@code Sender.build()}
651+
* keeps it across engine construction and connect, and closes the engine from
652+
* inside that scope when connect fails -- and a fresh slot is "fully drained" by
653+
* definition ({@code publishedFsn() < 0}), so the default path would unlink the
654+
* lock file while build() still holds the flock on it. On POSIX that frees the
655+
* pathname without releasing the lock, so the next {@code acquireLogical} creates
656+
* a SECOND inode and locks it successfully: two parties owning a lock whose only
657+
* job is serialising the quarantine close-&gt;rename-&gt;recreate window.
658+
*/
659+
public synchronized void close(boolean reclaimLogicalSlotLock) {
644660
if (closed) return;
645661
closed = true;
646662
// Capture drain state BEFORE closing the ring — once the ring is
@@ -728,16 +744,20 @@ public synchronized void close() {
728744
PersistedSymbolDict.removeOrphan(sfDir);
729745
} catch (Throwable ignored) {
730746
}
731-
try {
732-
// The logical slot lock lives OUTSIDE the slot dir (in the
733-
// shared .slot-locks dir) so it survives a slot rename; the
734-
// fully-drained retirement that removes this slot's other
735-
// side-files must remove it too, or .slot-locks accumulates a
736-
// dead lock+pid pair per distinct slot name for the lifetime of
737-
// sf_dir. This engine still holds the directory-local lock, so
738-
// the best-effort unlink is safe (see SlotLock.removeOrphanLogical).
739-
SlotLock.removeOrphanLogical(sfDir);
740-
} catch (Throwable ignored) {
747+
if (reclaimLogicalSlotLock) {
748+
try {
749+
// The logical slot lock lives OUTSIDE the slot dir (in the
750+
// shared .slot-locks dir) so it survives a slot rename; the
751+
// fully-drained retirement that removes this slot's other
752+
// side-files must remove it too, or .slot-locks accumulates a
753+
// dead lock+pid pair per distinct slot name for the lifetime of
754+
// sf_dir. Holding the directory-local lock is NOT sufficient to
755+
// make this safe -- it says nothing about the LOGICAL lock, which
756+
// a caller higher in the same stack may hold. Only a caller that
757+
// knows it does not hold it may reclaim, hence the flag.
758+
SlotLock.removeOrphanLogical(sfDir);
759+
} catch (Throwable ignored) {
760+
}
741761
}
742762
}
743763
} finally {
@@ -812,7 +832,13 @@ public int recoverySymbolNativeCapacity() {
812832

813833
private RecoveredFrameAnalysis checkedRecoveryAnalysis(int baseline) {
814834
if (recoveredFrameAnalysis == null || recoveredFrameAnalysis.baseline() != baseline) {
815-
throw new IllegalStateException("recovery symbol baseline mismatch [expected="
835+
// UnreplayableSlotException, NOT IllegalStateException: Sender.build() routes
836+
// on the type, and only this type reaches its quarantine handler. A raw
837+
// IllegalStateException escapes build() instead, and because senderId is
838+
// stable and a not-fully-drained slot is retained on close, every restart
839+
// re-recovers the same slot and rethrows -- the application can never
840+
// construct a Sender, so it cannot even BUFFER new rows.
841+
throw new UnreplayableSlotException("recovery symbol baseline mismatch [expected="
816842
+ (recoveredFrameAnalysis == null ? "none" : recoveredFrameAnalysis.baseline())
817843
+ ", actual=" + baseline + ']');
818844
}

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -726,7 +726,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
726726
// simply starts empty and grows from the frames themselves.
727727
PersistedSymbolDict pd = engine.getPersistedSymbolDict();
728728
int persistedPrefixLen = 0;
729-
if (pd != null && pd.size() > 0) {
729+
if (pd != null && pd.recoveredSize() > 0) {
730730
int len = pd.loadedEntriesLen();
731731
if (len > 0) {
732732
// Seed by reference. The foreground loop takes ownership after
@@ -739,10 +739,12 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
739739
sentDictBytesCapacity = len;
740740
sentDictBytesLen = len;
741741
// Set the count only alongside the bytes so sentDictCount can
742-
// never claim symbols the mirror does not hold. A recovered slot
743-
// always has loadedEntriesLen > 0 when size > 0, so this is the
744-
// same result -- it just makes the coupling explicit.
745-
sentDictCount = pd.size();
742+
// never claim symbols the mirror does not hold -- and take it from
743+
// recoveredSize(), NOT the live size(). They differ: the recovery
744+
// heal (QwpWebSocketSender.healPersistedDictionary) appends the
745+
// frame-contributed suffix to the file before this loop is built, so
746+
// size() would already have run past the loaded byte region.
747+
sentDictCount = pd.recoveredSize();
746748
}
747749
}
748750
// ...and then from the surviving frames' own delta sections, exactly as the producer

0 commit comments

Comments
 (0)