Skip to content

fix(core): fix spurious table metadata reload failures - #7312

Merged
bluestreak01 merged 3 commits into
masterfrom
correct_return_error_no
Jun 25, 2026
Merged

bluestreak01 merged 3 commits into
masterfrom
correct_return_error_no

Conversation

@kafka1991

@kafka1991 kafka1991 commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Problem

MemoryCMRImpl.of() read errno after the cleanup close() call when a file's length could not be read:

newSize = ff.length(fd);
if (newSize < 0) {
    close();                                     // native cleanup can overwrite errno
    throw CairoException.critical(ff.errno())...
}

errno is thread-local global state. The intervening close() performs a native close() syscall (whenever the fd-cache reference count for the descriptor drops to zero), which can overwrite the errno left by the failed length(). The exception then carried the cleanup call's errno instead of the real one.

This matters because metadata reload classifies the failure by errno: TableUtils.handleMetadataLoadException() retries the read (until the spin-lock timeout, then reports a clean "Metadata read timeout") only while CairoException.isFileCannotRead() is true. With a clobbered errno, a transient "file does not exist" condition — for example a _meta file that is briefly unreadable while a concurrent metadata change swaps it — was misclassified as fatal, and the lower-level "could not get length" error surfaced instead of the read being retried.

The behavior was non-deterministic because whether close() overwrites errno depends on transient fd-cache state, which is why it showed up as a flaky TableReaderTest#testMetadataFileDoesNotExist2.

Found in

2026-06-23T02:17:59.7943999Z 2026-06-23T02:17:59.794291Z I i.q.t.AbstractTest Starting test TableReaderTest#testMetadataFileDoesNotExist2
2026-06-23T02:17:59.7946312Z 2026-06-23T02:17:59.794538Z A i.q.c.DebugUtils Configuration issues:
2026-06-23T02:17:59.7946803Z     Deprecated settings (recognized but optionally superseded by newer settings):
2026-06-23T02:17:59.7947223Z         * cairo.sql.sort.value.max.pages: Replaced by `cairo.sql.sort.value.max.bytes`
2026-06-23T02:17:59.7947550Z         * cairo.sql.sort.key.max.pages: Replaced by `cairo.sql.sort.key.max.bytes`
2026-06-23T02:17:59.7948137Z         * cairo.sql.sort.light.value.max.pages: Replaced by `cairo.sql.sort.light.value.max.bytes`
2026-06-23T02:17:59.7948304Z 
2026-06-23T02:17:59.7955011Z 2026-06-23T02:17:59.795391Z I i.q.c.t.t.InputFormatConfiguration loading input format config [resource=/text_loader.json]
2026-06-23T02:17:59.7960464Z 2026-06-23T02:17:59.795944Z I i.q.c.TableNameRegistryStore reloading tables file [path=/tmp/junit8882504400344085756/dbRoot/tables.d.0, threadId=870]
2026-06-23T02:17:59.7973842Z 2026-06-23T02:17:59.797261Z A i.q.c.DebugUtils Configuration issues:
2026-06-23T02:17:59.7974471Z     Deprecated settings (recognized but optionally superseded by newer settings):
2026-06-23T02:17:59.7974851Z         * cairo.sql.sort.value.max.pages: Replaced by `cairo.sql.sort.value.max.bytes`
2026-06-23T02:17:59.7975174Z         * cairo.sql.sort.key.max.pages: Replaced by `cairo.sql.sort.key.max.bytes`
2026-06-23T02:17:59.7975505Z         * cairo.sql.sort.light.value.max.pages: Replaced by `cairo.sql.sort.light.value.max.bytes`
2026-06-23T02:17:59.7975673Z 
2026-06-23T02:17:59.7978551Z 2026-06-23T02:17:59.797762Z I i.q.c.t.t.InputFormatConfiguration loading input format config [resource=/text_loader.json]
2026-06-23T02:17:59.7981376Z 2026-06-23T02:17:59.798031Z I i.q.c.PartitionOverwriteControl acquiring partitions [table=testMetadataFileDoesNotExist~, readerTxn=0]
2026-06-23T02:17:59.7982040Z 2026-06-23T02:17:59.798117Z I i.q.c.p.WriterPool open [table=testMetadataFileDoesNotExist~, thread=870]
2026-06-23T02:17:59.7982599Z 2026-06-23T02:17:59.798150Z I i.q.c.TableWriter open 'testMetadataFileDoesNotExist~'
2026-06-23T02:17:59.7985951Z 2026-06-23T02:17:59.798498Z I i.q.c.TableWriter adding column 'col10[SYMBOL], columnName txn 0 to /testMetadataFileDoesNotExist~
2026-06-23T02:17:59.7999944Z 2026-06-23T02:17:59.799881Z I i.q.c.MetadataCache hydrated [table=testMetadataFileDoesNotExist~]
2026-06-23T02:17:59.8003927Z 2026-06-23T02:17:59.800274Z I i.q.t.s.TestFilesFacadeImpl cannot remove, file is open: /tmp/junit8882504400344085756/dbRoot/testMetadataFileDoesNotExist~.lock, fd=161061360757
2026-06-23T02:17:59.8004569Z 2026-06-23T02:17:59.800302Z I i.q.c.p.WriterPool closed [table=testMetadataFileDoesNotExist~, reason=POOL_CLOSED, by=870]
2026-06-23T02:17:59.8008215Z 2026-06-23T02:17:59.800640Z E i.q.t.AbstractCairoTest Exception in test: java.lang.AssertionError: 'could not get length: /tmp/junit8882504400344085756/dbRoot/testMetadataFileDoesNotExist~/_meta' does not contain: Metadata read timeout
2026-06-23T02:17:59.8008844Z 	at org.junit.Assert.fail(Assert.java:89)
2026-06-23T02:17:59.8009288Z 	at io.questdb.test.tools.TestUtils.assertContains(TestUtils.java:217)
2026-06-23T02:17:59.8009766Z 	at io.questdb.test.tools.TestUtils.assertContains(TestUtils.java:221)
2026-06-23T02:17:59.8010175Z 	at io.questdb.test.cairo.TableReaderTest.lambda$testMetadataFileDoesNotExist2$0(TableReaderTest.java:2008)
2026-06-23T02:17:59.8010568Z 	at io.questdb.test.AbstractCairoTest.lambda$assertMemoryLeak$0(AbstractCairoTest.java:688)
2026-06-23T02:17:59.8010926Z 	at io.questdb.test.tools.TestUtils.assertMemoryLeak(TestUtils.java:861)
2026-06-23T02:17:59.8011283Z 	at io.questdb.test.AbstractCairoTest.assertMemoryLeak(AbstractCairoTest.java:683)
2026-06-23T02:17:59.8011646Z 	at io.questdb.test.AbstractCairoTest.assertMemoryLeak(AbstractCairoTest.java:666)
2026-06-23T02:17:59.8012024Z 	at io.questdb.test.cairo.TableReaderTest.testMetadataFileDoesNotExist2(TableReaderTest.java:1966)
2026-06-23T02:17:59.8012404Z 	at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
2026-06-23T02:17:59.8012757Z 	at java.lang.reflect.Method.invoke(Method.java:565)
2026-06-23T02:17:59.8013102Z 	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
2026-06-23T02:17:59.8013478Z 	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
2026-06-23T02:17:59.8013952Z 	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
2026-06-23T02:17:59.8014491Z 	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
2026-06-23T02:17:59.8015107Z 	at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
2026-06-23T02:17:59.8015468Z 	at org.junit.internal.runners.statements.RunAfters.evaluate(RunAfters.java:27)
2026-06-23T02:17:59.8015799Z 	at org.junit.rules.TestWatcher$1.evaluate(TestWatcher.java:61)
2026-06-23T02:17:59.8016161Z 	at org.junit.internal.runners.statements.FailOnTimeout$CallableStatement.call(FailOnTimeout.java:299)
2026-06-23T02:17:59.8016637Z 	at org.junit.internal.runners.statements.FailOnTimeout$CallableStatement.call(FailOnTimeout.java:293)
2026-06-23T02:17:59.8016994Z 	at java.util.concurrent.FutureTask.run(FutureTask.java:328)
2026-06-23T02:17:59.8017294Z 	at java.lang.Thread.run(Thread.java:1474)
2026-06-23T02:17:59.8017435Z 
2026-06-23T02:17:59.8017740Z 2026-06-23T02:17:59.801301Z I i.q.t.AbstractCairoTest Tearing down test TableReaderTest#testMetadataFileDoesNotExist2
2026-06-23T02:17:59.8018116Z 2026-06-23T02:17:59.801361Z I i.q.t.AbstractTest Finished test TableReaderTest#testMetadataFileDoesNotExist2
2026-06-23T02:17:59.8068462Z >>>>= io.questdb.test.cairo.TableReaderTest.testMetadataFileDoesNotExist2
2026-06-23T02:17:59.8070341Z 2026-06-23T02:17:59.806687Z E i.q.t.TestListener ***** Test Failed ***** io.questdb.test.cairo.TableReaderTest.testMetadataFileDoesNotExist2 duration_ms=13 : java.lang.AssertionError: 'could not get length: /tmp/junit8882504400344085756/dbRoot/testMetadataFileDoesNotExist~/_meta' does not contain: Metadata read timeout
2026-06-23T02:17:59.8071070Z 	at org.junit.Assert.fail(Assert.java:89)
2026-06-23T02:17:59.8071480Z 	at io.questdb.test.tools.TestUtils.assertContains(TestUtils.java:217)
2026-06-23T02:17:59.8071959Z 	at io.questdb.test.tools.TestUtils.assertContains(TestUtils.java:221)
2026-06-23T02:17:59.8072457Z 	at io.questdb.test.cairo.TableReaderTest.lambda$testMetadataFileDoesNotExist2$0(TableReaderTest.java:2008)
2026-06-23T02:17:59.8072859Z 	at io.questdb.test.AbstractCairoTest.lambda$assertMemoryLeak$0(AbstractCairoTest.java:688)
2026-06-23T02:17:59.8073225Z 	at io.questdb.test.tools.TestUtils.assertMemoryLeak(TestUtils.java:861)
2026-06-23T02:17:59.8073854Z 	at io.questdb.test.AbstractCairoTest.assertMemoryLeak(AbstractCairoTest.java:683)
2026-06-23T02:17:59.8074357Z 	at io.questdb.test.AbstractCairoTest.assertMemoryLeak(AbstractCairoTest.java:666)
2026-06-23T02:17:59.8074797Z 	at io.questdb.test.cairo.TableReaderTest.testMetadataFileDoesNotExist2(TableReaderTest.java:1966)
2026-06-23T02:17:59.8075181Z 	at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
2026-06-23T02:17:59.8075522Z 	at java.lang.reflect.Method.invoke(Method.java:565)
2026-06-23T02:17:59.8075857Z 	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
2026-06-23T02:17:59.8076223Z 	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
2026-06-23T02:17:59.8076701Z 	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
2026-06-23T02:17:59.8077217Z 	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
2026-06-23T02:17:59.8077599Z 	at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
2026-06-23T02:17:59.8077960Z 	at org.junit.internal.runners.statements.RunAfters.evaluate(RunAfters.java:27)
2026-06-23T02:17:59.8078299Z 	at org.junit.rules.TestWatcher$1.evaluate(TestWatcher.java:61)
2026-06-23T02:17:59.8078661Z 	at org.junit.internal.runners.statements.FailOnTimeout$CallableStatement.call(FailOnTimeout.java:299)
2026-06-23T02:17:59.8079052Z 	at org.junit.internal.runners.statements.FailOnTimeout$CallableStatement.call(FailOnTimeout.java:293)
2026-06-23T02:17:59.8079408Z 	at java.util.concurrent.FutureTask.run(FutureTask.java:328)
2026-06-23T02:17:59.8079712Z 	at java.lang.Thread.run(Thread.java:1474)
2026-06-23T02:17:59.8079858Z 

Fix

Capture errno immediately after the failed length() call, before close(). This mirrors the existing convention in the native rename path in files.c, which saves and restores errno around close().

A wider sweep of errno reads that follow a cleanup call turned up no other genuine occurrences: the look-alikes either run a fresh failing syscall between the cleanup and the errno read (so the value is already correct), or the intervening close() does no native work on the failure path (e.g. TableTransactionLog only munmaps when a mapping exists, and the mmap had just failed).

Tradeoffs

The change is a one-line reordering. It has no effect other than carrying the correct errno, and no performance impact.

Test plan

  • Added CairoMemoryTest#testSmallFileReportsLengthErrnoNotCloseErrno. It drives MemoryCMRImpl.smallFile() through a FilesFacade whose length() fails with ENOENT and whose close() deterministically clobbers errno with EBADF, then asserts the thrown CairoException reports the length() errno (isFileCannotRead() true). The reproduction is deterministic and platform-independent.
  • Confirmed the new test fails before the fix (expected:<2> but was:<9>) and passes after.
  • TableReaderTest#testMetadataFileDoesNotExist2, the originally flaky test, passes.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b0066a86-113e-4e14-b1b4-28bf082bfc0d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch correct_return_error_no

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kafka1991 kafka1991 added Bug Incorrect or unexpected behavior Core Related to storage, data type, etc. labels Jun 23, 2026
@kafka1991 kafka1991 changed the title fix(core): return correct errno fix(core): fix spurious table metadata reload failures Jun 23, 2026
@kafka1991

Copy link
Copy Markdown
Contributor Author

Code review (level 3 — full pass)

Verdict: approve. Correct, minimal, well-targeted fix with a strong deterministic regression test. No Critical or Moderate issues. One Minor portability note.

What the change does

MemoryCMRImpl.of() read ff.errno() after the cleanup close() on the length()-failure path. errno is thread-local native state, and close() issues a native close(2) syscall (when the fd-cache refcount hits zero) that can overwrite it, so the exception carried the cleanup call's errno instead of length()'s. Metadata-reload classification keys off this errno (CairoException.isFileCannotRead() -> retry vs. fatal), so a transient "file does not exist" was misreported as a fatal "could not get length". The fix captures errno into a local before close().

Verification performed

  • Fix correctness (in-diff): FilesFacadeImpl.errno() -> Os.errno() reads the live thread-local errno; capturing it right after the failed ff.length(fd) and before close() is correct. The captured value is unambiguously from length() since fd was just opened successfully.
  • Fix completeness: Walked the rest of of(). The other throw sites (openFile->TableUtils.openRO, map->TableUtils.mapRO) build their CairoException at the failure site before any cleanup runs, then map()'s catch / of()'s outer catch rethrow the already-built object — no errno re-read after cleanup. The diff fixes the only affected path.
  • "Wider sweep" claim (out-of-diff): Independently swept core/src/main/java for the same anti-pattern. The closest analogues — MemoryCMORImpl.growToFileSize:108 and MemoryPMARImpl.truncate:155 — both read ff.errno() immediately after the failing syscall with no intervening cleanup, so they're correct. TableTransactionLog, IndexBuilder, MapWriter, TableWriter, Mig607 either run a fresh failing syscall between cleanup and the errno read, do no native work on the failure path, or read errno from the failing close itself. The claim holds.
  • Test validity: The length() override returns Files.length(missing) (sets errno = ENOENT = 2), and the close() override calls Files.closeDetached(-1) (sets errno = EBADF = 9), deterministically reproducing the clobber. This matches the reported "expected:<2> but was:<9>" before the fix exactly. Deterministic, no RNG, no timing.
  • Conventions: New test is in correct alphabetical position (testReadWriteMemoryTruncate < testSmallFile... < testWriteAndRead); wrapped in TestUtils.assertMemoryLeak; resources in try-with-resources. Not a SQL-result test, so the assertQuery builder rules don't apply.

Minor

CairoMemoryTest.java — exact-value errno assertion is slightly less portable than necessary.

Assert.assertEquals(CairoException.ERRNO_FILE_DOES_NOT_EXIST, e.getErrno()); // == 2

Files.isErrnoFileDoesNotExist() treats both 2 and (on Windows) 3 (ERROR_PATH_NOT_FOUND) as "file does not exist" — i.e. the codebase explicitly anticipates this condition surfacing as errno 3 on Windows. The test pins to exactly 2. In practice the path here (<existing-root>/definitely_missing) has only the leaf missing, so Windows should return ERROR_FILE_NOT_FOUND (2) and the test passes — but the hard equality is a needless cross-platform fragility. Since the test already asserts e.isFileCannotRead() (the property that actually matters for the metadata-retry behavior this PR fixes), consider dropping the exact-value check or using Assert.assertTrue(Files.isErrnoFileDoesNotExist(e.getErrno())). Not blocking.

Summary

Correct fix, complete for the documented bug, with a deterministic platform-independent regression test that fails before and passes after. No regressions or tradeoffs — the change only affects the error path and has no performance impact. The substantive verification was out-of-diff (sweep of sibling Memory*Impl classes and other errno sites), which confirmed this is the only place affected, consistent with a genuinely one-line bug.

🤖 Generated with Claude Code

@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 2 / 2 (100.00%)

file detail

path covered line new line coverage
🔵 io/questdb/cairo/vm/MemoryCMRImpl.java 2 2 100.00%

@bluestreak01 bluestreak01 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve.

Correct, minimal, complete fix. MemoryCMRImpl.of() captured errno after the cleanup close() on the length-failure path; since errno is thread-local native state and close() issues a native close(2) that overwrites it, the CairoException carried the cleanup's errno instead of length()'s. handleMetadataLoadException() keys retry-vs-fatal off isFileCannotRead(), so a transient ENOENT got misclassified as fatal (the flaky testMetadataFileDoesNotExist2). Capturing errno into a local before close() is the right fix.

Verified out-of-diff:

  • Other throw sites in both of() variants build their CairoException at the failure site (openRO, mapRO) before any cleanup; the outer catch rethrows an already-built exception. The length-from-fd path was the only affected site.
  • Swept sibling errno sites: MemoryCMORImpl.growToFileSize, MemoryPMARImpl.truncate, TableTransactionLog (close() does no native work after a failed mmap; fds are locals closed in finally after the errno read), Mig607, MapWriter, TableWriter.closeRemove, DatabaseCheckpointAgent — all read errno from a fresh failing syscall or do no native cleanup first. This is the only genuine occurrence.

The regression test is deterministic and platform-independent: length() override returns ENOENT(2), close() override clobbers errno to EBADF(9) via closeDetached(-1), matching the reported pre-fix expected:<2> but was:<9>. Correct alphabetical placement, try-with-resources inside assertMemoryLeak, portable isErrnoFileDoesNotExist assertion.

No Critical or Moderate issues. No regressions or performance impact.

@bluestreak01 bluestreak01 added READY PR is ready for the final review QUEUED FOR MERGE Approved PR in the merge queue. Do not merge master into this PR. labels Jun 24, 2026
@bluestreak01
bluestreak01 merged commit 2d0cdfa into master Jun 25, 2026
37 checks passed
@bluestreak01
bluestreak01 deleted the correct_return_error_no branch June 25, 2026 11:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Incorrect or unexpected behavior Core Related to storage, data type, etc. QUEUED FOR MERGE Approved PR in the merge queue. Do not merge master into this PR. READY PR is ready for the final review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants