Skip to content

Commit c1ad81a

Browse files
committed
Fail closed on unreadable SF segments and enumeration errors in recovery
SegmentRing.openExisting() previously skipped every per-file MmapSegmentException and treated directory-enumeration failure (findFirst < 0) as an empty store. The interior FSN-contiguity check cannot expose a skipped segment at the lowest, highest, or sole position -- the survivors stay mutually contiguous -- so recovery silently retired the skipped segment's unacked frames (lowest/sole: the engine seeds ackedFsn past them), minted duplicate FSNs (highest: nextSeq resumes inside the skipped range), or -- for a sole unreadable segment -- returned null, routing CursorSendEngine onto the fresh-start path whose truncating openCleanRW (O_CREAT|O_TRUNC / CREATE_ALWAYS) destroyed the only surviving sf-initial.sfa. Recovery now fails closed: - an unreadable .sfa aborts recovery with MmapSegmentException naming the file and remediation options (restore, or move the file out to explicitly accept the loss); - enumeration failure throws instead of returning null; - quarantine-rename and empty-leftover-unlink failures throw instead of warn-and-continue; - CursorSendEngine's fresh-start path refuses to create sf-initial.sfa over an existing file (defense in depth), checked before the stale-watermark unlink to preserve forensic state. openExisting() returning null now strictly means "no recoverable data existed". BackgroundDrainer already quarantines a slot (markFailed + FAILED) when engine construction throws, so corrupt orphan slots are preserved for postmortem rather than drained over. New SegmentRingEdgeRecoveryTest pins the contract with sole / lowest / highest unreadable-segment tests, an engine-level no-truncation test, and a POSIX enumeration-failure test; all five reproduced the data loss before the fix.
1 parent a375a78 commit c1ad81a

5 files changed

Lines changed: 524 additions & 54 deletions

File tree

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

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -316,21 +316,36 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
316316
recoveredCommitBoundaryFsn, publishedFsn);
317317
}
318318
} else {
319-
// Fresh start with no recovered segments. Any stale
320-
// watermark from a prior fully-drained session refers
321-
// to a lifecycle now gone -- unlink it before opening
322-
// so the new session's first read() correctly reports
323-
// INVALID (magic=0 on a freshly zero-filled file).
324-
if (!memoryMode) {
325-
AckWatermark.removeOrphan(sfDir);
326-
watermarkInProgress = AckWatermark.open(sfDir);
327-
}
319+
// Fresh start with no recovered segments.
328320
MmapSegment initial;
329321
String initialPath = null;
330322
if (memoryMode) {
331323
initial = MmapSegment.createInMemory(0L, segmentSizeBytes);
332324
} else {
333325
initialPath = sfDir + "/sf-initial.sfa";
326+
// Defense in depth: recovery returning null now guarantees
327+
// no recognizable segment data remained (unreadable files
328+
// and enumeration failures fail closed; empty leftovers
329+
// were unlinked; corrupt-tailed ones quarantined). If a
330+
// file still exists at this path, some invariant broke --
331+
// and MmapSegment.create's openCleanRW uses truncating
332+
// semantics (O_CREAT|O_TRUNC / CREATE_ALWAYS), so creating
333+
// over it would silently destroy whatever it holds.
334+
// Checked BEFORE the watermark unlink below so a guard
335+
// trip preserves maximal forensic state for postmortem.
336+
if (Files.exists(initialPath)) {
337+
throw new MmapSegmentException(
338+
"refusing to overwrite existing " + initialPath
339+
+ " on fresh start -- recovery reported no data, yet the"
340+
+ " file exists; creating over it would truncate its"
341+
+ " contents");
342+
}
343+
// Any stale watermark from a prior fully-drained session
344+
// refers to a lifecycle now gone -- unlink it before
345+
// opening so the new session's first read() correctly
346+
// reports INVALID (magic=0 on a freshly zero-filled file).
347+
AckWatermark.removeOrphan(sfDir);
348+
watermarkInProgress = AckWatermark.open(sfDir);
334349
initial = MmapSegment.create(initialPath, 0L, segmentSizeBytes);
335350
}
336351
try {

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

Lines changed: 77 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -146,16 +146,24 @@ public SegmentRing(MmapSegment initialActive, long maxBytesPerSegment) {
146146
* spare, then rotates onto it.</li>
147147
* <li>All others become sealed segments awaiting ACK and trim.</li>
148148
* </ul>
149-
* Returns {@code null} if the directory is empty or contains no
150-
* recognizable {@code .sfa} files -- the caller should then construct a
151-
* fresh ring with {@link #SegmentRing(MmapSegment, long)} and a freshly
152-
* created initial segment.
149+
* Returns {@code null} only when there is genuinely no data to recover:
150+
* the directory does not exist, is empty, or contains only empty
151+
* hot-spare leftovers (which are unlinked). The caller may then safely
152+
* construct a fresh ring with {@link #SegmentRing(MmapSegment, long)} and
153+
* a freshly created initial segment.
153154
* <p>
154-
* Recovery is best-effort: a single bad-magic file is silently skipped
155-
* (logged-then-ignored is the right call here; a stray unrelated file in
156-
* the SF dir shouldn't take the whole sender down). A failure to open
157-
* an otherwise-valid segment IS fatal -- the caller's data integrity
158-
* depends on every segment being readable.
155+
* Recovery fails closed: any {@code .sfa} file that cannot be opened --
156+
* bad magic, bad header, unsupported version, short file, mmap rejection
157+
* -- aborts recovery with {@link MmapSegmentException}, as does a failure
158+
* to enumerate the directory. Skipping an unreadable file is never safe
159+
* here: the interior FSN-contiguity check below cannot expose a skipped
160+
* segment at the lowest, highest, or sole position (the survivors stay
161+
* mutually contiguous), so recovery would silently retire the skipped
162+
* segment's unacked frames, mint duplicate FSNs past a skipped tail, or
163+
* -- for a sole skipped file -- route the caller onto the truncating
164+
* fresh-start path that destroys the only surviving segment. An operator
165+
* who wants to accept the loss moves the file out of the SF directory
166+
* explicitly.
159167
*/
160168
public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
161169
if (!Files.exists(sfDir)) {
@@ -164,9 +172,16 @@ public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
164172
ObjList<MmapSegment> opened = new ObjList<>();
165173
long find = Files.findFirst(sfDir);
166174
if (find < 0) {
167-
LOG.warn("openExisting could not enumerate {} - treating as empty, "
168-
+ "but this may indicate a permission or transient error", sfDir);
169-
return null;
175+
// Fail closed: an enumeration failure (permissions, transient
176+
// I/O error) is NOT an empty store. Returning null here would
177+
// route the caller onto the fresh-start path, whose truncating
178+
// openCleanRW of sf-initial.sfa destroys any surviving data the
179+
// JVM merely could not list.
180+
throw new MmapSegmentException(
181+
"could not enumerate SF directory " + sfDir
182+
+ " -- refusing to treat an unreadable directory as an empty"
183+
+ " store (a fresh start would truncate sf-initial.sfa over"
184+
+ " any surviving data); check directory permissions");
170185
}
171186
if (find == 0) {
172187
return null;
@@ -185,7 +200,30 @@ public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
185200
String path = sfDir + "/" + name;
186201
MmapSegment seg = null;
187202
try {
188-
seg = MmapSegment.openExisting(path);
203+
try {
204+
seg = MmapSegment.openExisting(path);
205+
} catch (MmapSegmentException t) {
206+
// Fail closed: an unreadable .sfa in the slot is a
207+
// recovery failure, not a skippable curiosity. The
208+
// interior contiguity check below cannot expose a
209+
// skipped segment at the lowest, highest, or sole
210+
// position -- the survivors stay mutually
211+
// contiguous -- so skipping would silently retire
212+
// the segment's unacked frames (lowest/sole: the
213+
// engine seeds ackedFsn past them), mint duplicate
214+
// FSNs (highest: nextSeq resumes inside the skipped
215+
// range), or -- sole -- route the caller onto the
216+
// truncating fresh-start path that destroys the
217+
// only surviving file. The outer catch closes every
218+
// already-recovered segment before this propagates.
219+
throw new MmapSegmentException(
220+
"recovery failed: unreadable segment file " + path
221+
+ " -- refusing to start with partial data (a skipped"
222+
+ " segment silently loses or duplicates frames). Restore"
223+
+ " the file from a copy, or move it out of the SF"
224+
+ " directory to explicitly accept the data loss",
225+
t);
226+
}
189227
// Filter out empty leftovers -- typically hot-spare
190228
// segments the manager pre-allocated for a prior
191229
// session that never got rotated into active. They
@@ -213,30 +251,39 @@ public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
213251
if (torn > 0) {
214252
int renameResult = Files.rename(path, path + ".corrupt");
215253
if (renameResult != 0) {
216-
LOG.warn("openExisting: could not quarantine corrupt segment {}; "
217-
+ "the original file remains in place [rc={}]",
218-
path, renameResult);
254+
// Fail closed: with the quarantine rename
255+
// failed, the corrupt file is still at its
256+
// original path -- if it is the sole
257+
// segment, the caller's fresh start would
258+
// truncate it (sf-initial.sfa) or restart
259+
// FSNs alongside it. Surface the error
260+
// instead of proceeding over live data.
261+
throw new MmapSegmentException(
262+
"could not quarantine corrupt segment " + path
263+
+ " to " + path + ".corrupt [rc=" + renameResult
264+
+ "] -- refusing to continue recovery while the"
265+
+ " corrupt file occupies its original path");
219266
}
220267
} else {
221-
Files.remove(path);
268+
if (!Files.remove(path)) {
269+
// Same fail-closed reasoning: an empty
270+
// leftover that cannot be unlinked would
271+
// be truncated by the caller's fresh
272+
// start (harmless for an empty file) --
273+
// but unlink failing in a directory we
274+
// just created files in means something
275+
// is wrong (permissions changed, dir
276+
// vanished); don't build on that ground.
277+
throw new MmapSegmentException(
278+
"could not unlink empty leftover segment " + path
279+
+ " -- refusing to continue recovery; check"
280+
+ " directory permissions");
281+
}
222282
}
223283
} else {
224284
opened.add(seg);
225285
seg = null;
226286
}
227-
} catch (MmapSegmentException t) {
228-
// Per-file data error (bad magic, bad header,
229-
// unsupported version, mmap rejection on this one
230-
// file). Don't take down recovery for one corrupt
231-
// .sfa -- log and skip so siblings still recover.
232-
// Resource exhaustion (OOM) and programmer errors
233-
// (IOOBE) deliberately propagate to the outer
234-
// catch, which closes every already-recovered
235-
// segment and rethrows: continuing the loop after
236-
// an OOM would just fail again on the next file
237-
// while silently leaking the segments we managed
238-
// to recover before it.
239-
LOG.warn("openExisting: skipping {} -- {}", path, t.toString());
240287
} finally {
241288
// Close any seg whose ownership wasn't transferred
242289
// (either to opened, or via the empty-branch close

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -208,12 +208,15 @@ public void testRecoverySurvivesPayloadReachingUnbackedPage() throws Exception {
208208
}
209209

210210
/**
211-
* An unbacked page-zero header must produce the per-file
212-
* {@link MmapSegmentException} that SegmentRing skips, not poison recovery
213-
* of valid sibling files.
211+
* An unbacked page-zero header must produce a synchronous
212+
* {@link MmapSegmentException}, not a SIGBUS/undefined mapping.
213+
* SegmentRing.openExisting fails recovery closed on this exception --
214+
* a segment with an unreadable header may hold recoverable frames, and
215+
* silently skipping it at the lowest/highest/sole position loses or
216+
* duplicates FSNs (see SegmentRingEdgeRecoveryTest).
214217
*/
215218
@Test
216-
public void testUnbackedHeaderPageIsSkippableNotFatal() throws Exception {
219+
public void testUnbackedHeaderPageFailsSynchronously() throws Exception {
217220
TestUtils.assertMemoryLeak(() -> {
218221
final String path = tmpDir + "/seg-unbacked-header.sfa";
219222
writeSegment(path, 3L, new int[]{64});
@@ -223,8 +226,9 @@ public void testUnbackedHeaderPageIsSkippableNotFatal() throws Exception {
223226
MmapSegment.openExisting(path).close();
224227
fail("expected MmapSegmentException for an unbacked header page");
225228
} catch (MmapSegmentException expected) {
226-
// ok -- SegmentRing's per-file catch skips just this file
227-
// instead of aborting recovery of the whole slot.
229+
// ok -- surfaces synchronously; SegmentRing.openExisting
230+
// fails recovery closed on it rather than silently skipping
231+
// a file that may hold recoverable frames.
228232
}
229233
});
230234
}
@@ -331,11 +335,12 @@ public void testSuccessfulRecoveryClosesThroughFacadeExactlyOnce() throws Except
331335
}
332336

333337
/**
334-
* A header short-read produces the per-file exception that SegmentRing
335-
* skips; recovery never maps the stale reported length.
338+
* A header short-read produces a synchronous per-file exception on which
339+
* SegmentRing.openExisting fails recovery closed; recovery never maps
340+
* the stale reported length.
336341
*/
337342
@Test
338-
public void testHeaderShortReadIsSkippable() throws Exception {
343+
public void testHeaderShortReadFailsSynchronously() throws Exception {
339344
TestUtils.assertMemoryLeak(() -> {
340345
final String path = tmpDir + "/seg-mappasteof-header.sfa";
341346
final long page = Files.PAGE_SIZE;

0 commit comments

Comments
 (0)