Skip to content

Commit 63dc52a

Browse files
glasstigerclaude
andcommitted
Free the recovery mirror on a ctor seed throw
The CursorWebSocketSendLoop constructor mallocs the sent-dictionary mirror from the persisted dictionary's prefix, then extends it from the surviving frames' deltas. If that second step throws -- a native realloc OOM, or the MAX_SENT_DICT_BYTES ceiling -- the throw leaves the loop unpublished, so neither ensureConnected's catch nor BackgroundDrainer's finally can close() it, and the already-malloc'd mirror leaks. Free it on any throw, mirroring the loopNeverRan free in close()/ioLoop's exit. Also close two test-coverage gaps the review flagged: - The send loop's torn-dict guard (deltaStart > sentDictCount in trySendOne) is the drainer path's sole defense against replaying a frame whose symbols were trimmed away; the foreground path is quarantined earlier, so this fire direction was unexercised. Cover it at the send-loop level against a stub client (the drainer integration path cannot connect on darwin), asserting zero frames ship and the terminal latches. - quarantineTornSlot must fail loudly rather than drop bytes when it cannot set a slot aside. Saturate every quarantine candidate and assert build() throws and preserves the slot on disk. A TestOnly seam (forceMirrorSeedFailureForTest) makes the ctor seed throw deterministically; the unreplayable-slot setup is now a shared helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 98a4ac0 commit 63dc52a

4 files changed

Lines changed: 421 additions & 30 deletions

File tree

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

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,14 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
197197
* Throttle "reconnect attempt N failed" WARN logs to one per 5 s.
198198
*/
199199
private static final long RECONNECT_LOG_THROTTLE_NANOS = 5_000_000_000L;
200+
// Test seam: when true, appendSymbolToMirror throws instead of appending,
201+
// simulating the native realloc OOM (or MAX_SENT_DICT_BYTES ceiling) that
202+
// ensureSentDictCapacity can raise while the constructor seeds the recovery
203+
// mirror. Lets a test prove the constructor frees the already-malloc'd mirror on
204+
// such a throw rather than leaking it. Production never sets it; volatile only so
205+
// a test thread's write is visible to the loop under test.
206+
@TestOnly
207+
static volatile boolean forceMirrorSeedFailureForTest;
200208
// Pre-converted to nanos for the comparison in sendDurableAckKeepaliveIfDue.
201209
// Zero or negative disables the keepalive entirely.
202210
private final long durableAckKeepaliveIntervalNanos;
@@ -717,11 +725,30 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
717725
// nothing and cost a catch-up frame on every connection -- full-dict slots must stay
718726
// catch-up-free.
719727
if (engine.recoveredMaxSymbolDeltaStart() > 0L) {
720-
ObjList<String> fromFrames = new ObjList<>();
721-
if (engine.collectReplaySymbolsAbove(sentDictCount, fromFrames) >= 0) {
722-
for (int i = 0, n = fromFrames.size(); i < n; i++) {
723-
appendSymbolToMirror(fromFrames.getQuick(i));
728+
// The prefix seed above may already have malloc'd the mirror, and
729+
// appendSymbolToMirror -> ensureSentDictCapacity can still throw here (a
730+
// native realloc OOM, or the MAX_SENT_DICT_BYTES ceiling). Such a throw
731+
// propagates out of the constructor, so the half-built loop is never
732+
// assigned to a reference -- neither ensureConnected's catch nor
733+
// BackgroundDrainer's finally can close() it -- and the mirror would leak.
734+
// Free it on any throw so the constructor leaves nothing behind, mirroring
735+
// the loopNeverRan free in close() / ioLoop's exit.
736+
try {
737+
ObjList<String> fromFrames = new ObjList<>();
738+
if (engine.collectReplaySymbolsAbove(sentDictCount, fromFrames) >= 0) {
739+
for (int i = 0, n = fromFrames.size(); i < n; i++) {
740+
appendSymbolToMirror(fromFrames.getQuick(i));
741+
}
724742
}
743+
} catch (Throwable t) {
744+
if (sentDictBytesAddr != 0) {
745+
Unsafe.free(sentDictBytesAddr, sentDictBytesCapacity, MemoryTag.NATIVE_DEFAULT);
746+
sentDictBytesAddr = 0;
747+
sentDictBytesCapacity = 0;
748+
sentDictBytesLen = 0;
749+
sentDictCount = 0;
750+
}
751+
throw t;
725752
}
726753
}
727754
this.fsnAtZero = fsnAtZero;
@@ -2299,6 +2326,10 @@ private void accumulateSentDict(long payloadAddr, int payloadLen, int deltaStart
22992326
* construction; {@link #accumulateSentDict} extends it from live frames thereafter.
23002327
*/
23012328
private void appendSymbolToMirror(CharSequence symbol) {
2329+
if (forceMirrorSeedFailureForTest) {
2330+
// Simulate a native realloc OOM on the seed path (see the field).
2331+
throw new LineSenderException("simulated mirror seed allocation failure (test only)");
2332+
}
23022333
int utf8Len = Utf8s.utf8Bytes(symbol);
23032334
int wireLen = NativeBufferWriter.varintSize(utf8Len) + utf8Len;
23042335
ensureSentDictCapacity((long) sentDictBytesLen + wireLen);

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictRecoveryTest.java

Lines changed: 77 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -223,32 +223,7 @@ public void testTrimmedRegisteringFramesAreUnreplayableAndTheSlotIsSetAside() th
223223
// retained on close, so every retry re-recovered the same slot and threw again, and the
224224
// application could not construct a Sender at all -- it could not even buffer new rows.
225225
assertMemoryLeak(() -> {
226-
try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
227-
int port = silent.getPort();
228-
silent.start();
229-
Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
230-
// Small segments so the frames roll into several files and the earliest ones
231-
// can be trimmed away independently.
232-
String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir
233-
+ ";sf_max_bytes=256;close_flush_timeout_millis=0;";
234-
try (Sender s1 = Sender.fromConfig(cfg)) {
235-
for (int i = 0; i < 12; i++) {
236-
s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
237-
s1.flush();
238-
}
239-
}
240-
}
241-
242-
java.nio.file.Path slot = Paths.get(sfDir, "default");
243-
Assert.assertTrue("the frames must have rolled into more than one segment",
244-
countSegmentFiles(slot) > 1);
245-
// TRIM the segment that holds the earliest frames -- munmap + unlink is exactly what
246-
// SegmentManager does once they are acked. Their symbols are now gone from disk.
247-
java.nio.file.Files.delete(slot.resolve("sf-initial.sfa"));
248-
// ...and the dictionary is torn away as well, so nothing holds them at all.
249-
java.nio.file.Path dict = slot.resolve(".symbol-dict");
250-
java.nio.file.Files.write(dict,
251-
Arrays.copyOf(java.nio.file.Files.readAllBytes(dict), 8));
226+
writeAndTearUnreplayableSlot();
252227

253228
DictReconstructingHandler handler = new DictReconstructingHandler();
254229
try (TestWebSocketServer good = new TestWebSocketServer(handler)) {
@@ -287,6 +262,82 @@ private static int countSegmentFiles(java.nio.file.Path dir) {
287262
return n;
288263
}
289264

265+
@Test
266+
public void testQuarantineFailsLoudlyWhenAllSlotNamesSaturated() throws Exception {
267+
// M2 regression: when a recovered slot is genuinely unreplayable, build() sets
268+
// it aside (quarantineTornSlot) and starts the producer on a fresh slot. But if
269+
// it cannot free the slot name -- here every default.unreplayable-<i> candidate
270+
// up to MAX_QUARANTINE_SLOT_ATTEMPTS (64) already exists -- it MUST fail LOUDLY:
271+
// throw and leave the slot's bytes on disk for a manual resend, never silently
272+
// drop data. Only the happy rename path was covered before.
273+
assertMemoryLeak(() -> {
274+
writeAndTearUnreplayableSlot();
275+
// Saturate every quarantine candidate so quarantineTornSlot's rename loop
276+
// finds no free name.
277+
for (int i = 0; i < 64; i++) {
278+
java.nio.file.Files.createDirectories(Paths.get(sfDir, "default.unreplayable-" + i));
279+
}
280+
281+
java.nio.file.Path slot = Paths.get(sfDir, "default");
282+
try (TestWebSocketServer good = new TestWebSocketServer(new DictReconstructingHandler())) {
283+
int port = good.getPort();
284+
good.start();
285+
Assert.assertTrue(good.awaitStart(5, TimeUnit.SECONDS));
286+
String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";";
287+
Sender s = null;
288+
try {
289+
s = Sender.fromConfig(cfg);
290+
Assert.fail("build() must throw when the unreplayable slot cannot be set aside");
291+
} catch (LineSenderException expected) {
292+
Assert.assertTrue("unexpected message: " + expected.getMessage(),
293+
expected.getMessage().contains("too many quarantined slots already under")
294+
&& expected.getMessage().contains("moved or removed by hand"));
295+
} finally {
296+
if (s != null) {
297+
s.close();
298+
}
299+
}
300+
}
301+
// The unreplayable slot's bytes must survive on disk for a manual resend --
302+
// the guard fails loudly rather than dropping data.
303+
Assert.assertTrue("the slot dir must be preserved", java.nio.file.Files.exists(slot));
304+
Assert.assertTrue("the slot's segment data must be preserved",
305+
countSegmentFiles(slot) >= 1);
306+
});
307+
}
308+
309+
// Writes 12 delta frames (each introducing a new symbol) into the default slot
310+
// across several small segments, then makes the slot GENUINELY unreplayable: trims
311+
// the segment holding the earliest ids (munmap + unlink, exactly what SegmentManager
312+
// does once they are acked) and tears the .symbol-dict down to its header, so the
313+
// surviving frames' deltas start above ids nothing on disk still holds. Recovering
314+
// such a slot throws UnreplayableSlotException.
315+
private void writeAndTearUnreplayableSlot() throws Exception {
316+
try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
317+
int port = silent.getPort();
318+
silent.start();
319+
Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
320+
// Small segments so the frames roll into several files and the earliest ones
321+
// can be trimmed away independently.
322+
String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir
323+
+ ";sf_max_bytes=256;close_flush_timeout_millis=0;";
324+
try (Sender s1 = Sender.fromConfig(cfg)) {
325+
for (int i = 0; i < 12; i++) {
326+
s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
327+
s1.flush();
328+
}
329+
}
330+
}
331+
java.nio.file.Path slot = Paths.get(sfDir, "default");
332+
Assert.assertTrue("the frames must have rolled into more than one segment",
333+
countSegmentFiles(slot) > 1);
334+
// TRIM the segment that holds the earliest frames.
335+
java.nio.file.Files.delete(slot.resolve("sf-initial.sfa"));
336+
// ...and tear the dictionary away as well, so nothing holds those ids at all.
337+
java.nio.file.Path dict = slot.resolve(".symbol-dict");
338+
java.nio.file.Files.write(dict, Arrays.copyOf(java.nio.file.Files.readAllBytes(dict), 8));
339+
}
340+
290341
@Test
291342
public void testUnopenableDictSeedsTheProducerAboveTheRecoveredIds() throws Exception {
292343
// The producer must resume ABOVE the ids the recovered frames already define, even when

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
2626

2727
import io.questdb.client.Sender;
28+
import io.questdb.client.cutlass.line.LineSenderException;
2829
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
2930
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
3031
import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict;
@@ -156,6 +157,63 @@ public void testRecycledLoopReSeedsMirrorFromPersistedDict() throws Exception {
156157
}
157158
}
158159

160+
@Test
161+
public void testCtorFreesSeededMirrorWhenFrameSeedThrows() throws Exception {
162+
// C1 regression: the constructor seeds the recovery mirror in TWO steps -- a
163+
// malloc from the persisted dictionary's intact prefix, then an extension from
164+
// the surviving frames' own deltas (appendSymbolToMirror). If that second step
165+
// throws (a native realloc OOM, or the MAX_SENT_DICT_BYTES ceiling), the throw
166+
// leaves the constructor with the object unpublished, so neither
167+
// ensureConnected's catch nor BackgroundDrainer's finally can ever close() it --
168+
// and the already-malloc'd prefix mirror leaks. The constructor must free it on
169+
// the throw. Pre-fix, the mirror leaks here and assertMemoryLeak fails.
170+
Path sfDir = Files.createTempDirectory("qwp-mirror-ctor-throw");
171+
try {
172+
// A torn-dict SUBSET: three delta frames a@0,b@1,c@2 survive on disk, but the
173+
// .symbol-dict is rewritten to hold only [a,b] (a host-crash tail tear). On
174+
// recovery pd.size()==2 seeds (mallocs) the mirror, then the frame-seed
175+
// rebuilds c@2 from the surviving frame -- the append the fault interrupts.
176+
populateThreeFrameSlot(sfDir);
177+
Path slot = sfDir.resolve("default");
178+
try (PersistedSymbolDict torn = PersistedSymbolDict.openClean(slot.toString())) {
179+
Assert.assertNotNull(torn);
180+
torn.appendSymbol("a");
181+
torn.appendSymbol("b");
182+
Assert.assertEquals(2, torn.size());
183+
}
184+
185+
assertMemoryLeak(() -> {
186+
try (CursorSendEngine engine = new CursorSendEngine(slot.toString(), 4096)) {
187+
PersistedSymbolDict pd = engine.getPersistedSymbolDict();
188+
Assert.assertNotNull("recovery must open the torn subset dict", pd);
189+
Assert.assertEquals("prefix seed must malloc a 2-entry mirror", 2, pd.size());
190+
Assert.assertTrue("the frame-seed path must run (frames out-reach the dict)",
191+
engine.recoveredMaxSymbolDeltaStart() > 0L);
192+
193+
setMirrorSeedFault(true);
194+
try {
195+
new CursorWebSocketSendLoop(
196+
null, engine, 0, 1_000_000L,
197+
() -> {
198+
throw new IOException("no reconnect in this test");
199+
},
200+
0, 0, 1);
201+
Assert.fail("ctor must propagate the injected mirror-seed failure");
202+
} catch (LineSenderException expected) {
203+
Assert.assertTrue("unexpected message: " + expected.getMessage(),
204+
expected.getMessage().contains("simulated mirror seed allocation failure"));
205+
} finally {
206+
setMirrorSeedFault(false);
207+
}
208+
// The outer assertMemoryLeak proves the prefix-seeded mirror the ctor
209+
// malloc'd was freed on the throw -- pre-fix it leaks here.
210+
}
211+
});
212+
} finally {
213+
rmDir(sfDir);
214+
}
215+
}
216+
159217
// Constructs a recovery send loop but does NOT start it: the ctor seeds the
160218
// catch-up mirror synchronously, which is all these tests observe. The
161219
// reconnect factory is never invoked.
@@ -189,12 +247,43 @@ private static void populateRecoverableSlot(Path sfDir) throws Exception {
189247
}
190248
}
191249

250+
// Three delta frames a@0, b@1, c@2, nothing acked, so all three survive and
251+
// replay from frame 0. Paired with a dictionary truncated to [a,b], this is a
252+
// torn-dict SUBSET whose recovery drives the constructor's frame-seed path.
253+
private static void populateThreeFrameSlot(Path sfDir) throws Exception {
254+
try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
255+
int port = silent.getPort();
256+
silent.start();
257+
Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
258+
String cfg = "ws::addr=localhost:" + port
259+
+ ";sf_dir=" + sfDir
260+
+ ";close_flush_timeout_millis=0;";
261+
try (Sender s = Sender.fromConfig(cfg)) {
262+
s.table("m").symbol("s", "a").longColumn("v", 0).atNow();
263+
s.flush();
264+
s.table("m").symbol("s", "b").longColumn("v", 1).atNow();
265+
s.flush();
266+
s.table("m").symbol("s", "c").longColumn("v", 2).atNow();
267+
s.flush();
268+
}
269+
}
270+
}
271+
192272
private static int readInt(CursorWebSocketSendLoop loop, String name) throws Exception {
193273
Field f = CursorWebSocketSendLoop.class.getDeclaredField(name);
194274
f.setAccessible(true);
195275
return f.getInt(loop);
196276
}
197277

278+
// Toggles the loop's @TestOnly mirror-seed fault flag. Reflection because the
279+
// flag is package-private in the production package (this test is in a sibling
280+
// test package), the same non-reflective-path-unavailable reason readInt uses.
281+
private static void setMirrorSeedFault(boolean value) throws Exception {
282+
Field f = CursorWebSocketSendLoop.class.getDeclaredField("forceMirrorSeedFailureForTest");
283+
f.setAccessible(true);
284+
f.setBoolean(null, value);
285+
}
286+
198287
private static void rmDir(Path dir) throws IOException {
199288
TestUtils.removeTmpDirRec(dir == null ? null : dir.toString());
200289
}

0 commit comments

Comments
 (0)