This is an automated email from the ASF dual-hosted git repository.

airborne12 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new e33e04823aa [fix](be) Stop two SNII unit tests from trampling shared 
state (#66961)
e33e04823aa is described below

commit e33e04823aa84b5aa988f836d756831cb6a89d66
Author: Jack <[email protected]>
AuthorDate: Thu Aug 20 09:47:03 2026 +0800

    [fix](be) Stop two SNII unit tests from trampling shared state (#66961)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A
    
    Related PR: N/A
    
    Problem Summary:
    
    Two cases under `be/test/storage/index/snii/` reach outside their own
    test and
    break unrelated cases. Both are test-only; no production code changes.
    
    **1. A fixed `/tmp` scratch path (`metered_file_reader_test.cpp`)**
    
    `MakeRampFile()` built its 256-byte ramp file at the constant path
    `/tmp/snii_metered_ramp.bin`. On a shared build machine that file
    belongs to
    whoever ran the suite first; every later user gets `EACCES` from
    `::open`, and
    all seven `SniiMeteredFileReader` cases fail at the first assertion with
    no hint
    that a foreign file is the cause:
    
    ```
    metered_file_reader_test.cpp:42: Failure
    Value of: w.open(path).ok()  Actual: false  Expected: true
    ```
    
    Every other scratch path in this subtree already namespaces itself with
    the pid
    and a counter, and the nearest neighbour in the same directory
    (`file_reader_caller_buffer_test.cpp`) also `std::remove()`s the file
    afterwards.
    This PR adopts both halves of that convention: the ramp file becomes a
    `RampFile`
    RAII object whose name carries the pid and a counter and whose
    destructor removes
    it. Removing matters as much as naming — a leftover file is what causes
    the
    collision in the first place, and pids get recycled.
    
    **2. A permanently lowered `RLIMIT_NOFILE`
    (`spimi_term_buffer_test.cpp`)**
    
    `SniiSpimiTermBuffer.SpillOpenIoFailureLatched` drives a spill's
    `::open` to
    `EMFILE` by lowering `RLIMIT_NOFILE` to a hardcoded 64 and then opening
    `/dev/null` until the table is full. Two things go wrong once the binary
    already
    holds more than 64 descriptors:
    
    1. The cap assumes the process holds fewer than 64 descriptors, so the
    hog loop
    can still take one. Sampling `/proc/<pid>/fd` during a run shows the
    SNII cases
    alone keep the process at **14** descriptors, but adding the CLucene
    inverted
    index cases to the same binary takes it to **273** — they hold hundreds
    of
    index files open at once. Above the cap the very first `::open` already
    fails,
    `hogs` stays empty, and the case stops at `ASSERT_FALSE(hogs.empty())`.
    What
       decides this is *which* cases share the binary, not how many.
    2. That assertion is fatal, so it returns from the test body — and the
    code that
    restores `RLIMIT_NOFILE` sits after it and never runs. The whole process
    stays
       capped at 64 descriptors for every later case.
    
    The result is not a descriptor leak but a cascade: the next four
    `SniiSpimiTermBuffer` cases fail instantly because they cannot open a
    spill file,
    and the first death test after them aborts the entire run when gtest's
    `pipe()`
    hits `EMFILE`:
    
    ```
    CHECK failed: gtest-death-test.cc, line 1400: pipe(pipe_fd) != -1
    Aborted
    ```
    
    The fix holds the tight limit and the hog descriptors in an inner scope
    whose
    `Defer` restores `RLIMIT_NOFILE` and closes the descriptors on every
    exit path,
    fatal assertion included; and derives the cap from
    `::dup(STDIN_FILENO)`, the
    lowest free descriptor, instead of a fixed 64, so the case no longer
    depends on
    which tests ran before it. The case still drives the spill open to
    `EMFILE` and
    still asserts the error is latched.
    
    **Before / after**, with
    
    
`--filter='*Snii*:*snii*:*SNII*:CollectionStatisticsTest.*:*InvertedIndex*:*inverted_index*'`:
    
    | | before | after |
    |---|---|---|
    | `SniiMeteredFileReader` | 7 failed | pass |
    | `SniiSpimiTermBuffer*` | 5 failed | pass |
    | death test | aborted the run | pass |
    | total | run truncated by the abort | 1693 passed, 0 failed |
---
 .../index/snii/io/metered_file_reader_test.cpp     | 60 +++++++++++++++-------
 .../index/snii/writer/spimi_term_buffer_test.cpp   | 59 ++++++++++++---------
 2 files changed, 77 insertions(+), 42 deletions(-)

diff --git a/be/test/storage/index/snii/io/metered_file_reader_test.cpp 
b/be/test/storage/index/snii/io/metered_file_reader_test.cpp
index a9fc344fb56..22c1a67d1da 100644
--- a/be/test/storage/index/snii/io/metered_file_reader_test.cpp
+++ b/be/test/storage/index/snii/io/metered_file_reader_test.cpp
@@ -18,8 +18,10 @@
 #include "storage/index/snii/io/metered_file_reader.h"
 
 #include <gtest/gtest.h>
+#include <unistd.h>
 
 #include <cstdint>
+#include <cstdio>
 #include <string>
 #include <vector>
 
@@ -35,27 +37,41 @@ using doris::snii::io::Range;
 
 namespace {
 
-// Writes 256 bytes (byte[i] = i) to a temp file and returns its path.
-std::string MakeRampFile() {
-    const std::string path = "/tmp/snii_metered_ramp.bin";
-    LocalFileWriter w;
-    EXPECT_TRUE(w.open(path).ok());
-    std::vector<uint8_t> data(256);
-    for (int i = 0; i < 256; ++i) {
-        data[i] = static_cast<uint8_t>(i);
+// Owns one scratch file holding 256 bytes (byte[i] = i) and removes it on 
scope
+// exit. The name carries the pid and a counter, like the other scratch paths 
in
+// this directory: a fixed /tmp name left behind by another user on a shared
+// machine makes ::open fail with EACCES and takes every test here down with 
it.
+class RampFile {
+public:
+    RampFile() {
+        static int counter = 0;
+        path_ = "/tmp/snii_metered_ramp_" + std::to_string(::getpid()) + "_" +
+                std::to_string(counter++) + ".bin";
+        LocalFileWriter w;
+        EXPECT_TRUE(w.open(path_).ok());
+        std::vector<uint8_t> data(256);
+        for (int i = 0; i < 256; ++i) {
+            data[i] = static_cast<uint8_t>(i);
+        }
+        EXPECT_TRUE(w.append(Slice(data)).ok());
+        EXPECT_TRUE(w.finalize().ok());
     }
-    EXPECT_TRUE(w.append(Slice(data)).ok());
-    EXPECT_TRUE(w.finalize().ok());
-    return path;
-}
+    ~RampFile() { std::remove(path_.c_str()); }
+
+    const std::string& path() const { return path_; }
+
+private:
+    std::string path_;
+};
 
 } // namespace
 
 // Single reads: first read to a block is a cache miss (1 round, 1 GET, 1 
block of
 // remote bytes); a second read to the same 16-byte block is a hit (no new 
round).
 TEST(SniiMeteredFileReader, SingleReadCacheAccounting) {
+    const RampFile ramp;
     LocalFileReader inner;
-    ASSERT_TRUE(inner.open(MakeRampFile()).ok());
+    ASSERT_TRUE(inner.open(ramp.path()).ok());
     MeteredFileReader m(&inner, /*block_size=*/16);
 
     std::vector<uint8_t> out;
@@ -84,8 +100,9 @@ TEST(SniiMeteredFileReader, SingleReadCacheAccounting) {
 
 // A read spanning 3 contiguous blocks is one round and one coalesced GET.
 TEST(SniiMeteredFileReader, SpanMultipleBlocksCoalesced) {
+    const RampFile ramp;
     LocalFileReader inner;
-    ASSERT_TRUE(inner.open(MakeRampFile()).ok());
+    ASSERT_TRUE(inner.open(ramp.path()).ok());
     MeteredFileReader m(&inner, 16);
 
     std::vector<uint8_t> out;
@@ -98,8 +115,9 @@ TEST(SniiMeteredFileReader, SpanMultipleBlocksCoalesced) {
 
 // A batch of reads to non-adjacent blocks: one serial round, one GET per run.
 TEST(SniiMeteredFileReader, BatchNonAdjacent) {
+    const RampFile ramp;
     LocalFileReader inner;
-    ASSERT_TRUE(inner.open(MakeRampFile()).ok());
+    ASSERT_TRUE(inner.open(ramp.path()).ok());
     MeteredFileReader m(&inner, 16);
 
     std::vector<Range> ranges = {{.offset = 0, .len = 4},
@@ -117,8 +135,9 @@ TEST(SniiMeteredFileReader, BatchNonAdjacent) {
 
 // A batch of reads to adjacent blocks coalesces into a single GET.
 TEST(SniiMeteredFileReader, BatchAdjacentCoalesced) {
+    const RampFile ramp;
     LocalFileReader inner;
-    ASSERT_TRUE(inner.open(MakeRampFile()).ok());
+    ASSERT_TRUE(inner.open(ramp.path()).ok());
     MeteredFileReader m(&inner, 16);
 
     std::vector<Range> ranges = {{.offset = 0, .len = 4},
@@ -134,8 +153,9 @@ TEST(SniiMeteredFileReader, BatchAdjacentCoalesced) {
 
 // reset_metrics clears both counters and the resident cache (cold query).
 TEST(SniiMeteredFileReader, ResetClearsCacheAndCounters) {
+    const RampFile ramp;
     LocalFileReader inner;
-    ASSERT_TRUE(inner.open(MakeRampFile()).ok());
+    ASSERT_TRUE(inner.open(ramp.path()).ok());
     MeteredFileReader m(&inner, 16);
 
     std::vector<uint8_t> out;
@@ -150,8 +170,9 @@ TEST(SniiMeteredFileReader, ResetClearsCacheAndCounters) {
 }
 
 TEST(SniiMeteredFileReader, InvalidRangeDoesNotPolluteMetrics) {
+    const RampFile ramp;
     LocalFileReader inner;
-    ASSERT_TRUE(inner.open(MakeRampFile()).ok());
+    ASSERT_TRUE(inner.open(ramp.path()).ok());
     MeteredFileReader m(&inner, 16);
 
     std::vector<uint8_t> out;
@@ -165,8 +186,9 @@ TEST(SniiMeteredFileReader, 
InvalidRangeDoesNotPolluteMetrics) {
 }
 
 TEST(SniiMeteredFileReader, InvalidBatchRangeDoesNotPolluteMetrics) {
+    const RampFile ramp;
     LocalFileReader inner;
-    ASSERT_TRUE(inner.open(MakeRampFile()).ok());
+    ASSERT_TRUE(inner.open(ramp.path()).ok());
     MeteredFileReader m(&inner, 16);
 
     std::vector<std::vector<uint8_t>> outs;
diff --git a/be/test/storage/index/snii/writer/spimi_term_buffer_test.cpp 
b/be/test/storage/index/snii/writer/spimi_term_buffer_test.cpp
index d845f549557..f508462670d 100644
--- a/be/test/storage/index/snii/writer/spimi_term_buffer_test.cpp
+++ b/be/test/storage/index/snii/writer/spimi_term_buffer_test.cpp
@@ -31,6 +31,7 @@
 
 #include "common/status.h"
 #include "storage/index/snii/writer/term_posting_test_utils.h"
+#include "util/defer_op.h"
 
 using doris::snii::writer::MemoryReporter;
 using doris::snii::writer::SpimiTermBuffer;
@@ -514,34 +515,46 @@ TEST(SniiSpimiTermBuffer, SpillOpenIoFailureLatched) {
     // Tiny threshold so the very first token triggers a spill_to_run().
     SpimiTermBuffer buf(/*has_positions=*/false, /*spill_threshold_bytes=*/1);
 
-    // Cap the soft limit low so we can exhaust the fd table cheaply, then 
hold it.
     struct rlimit saved {};
     ASSERT_EQ(getrlimit(RLIMIT_NOFILE, &saved), 0);
-    struct rlimit tight = saved;
-    tight.rlim_cur = 64; // small, but >= the few gtest/std fds already open
-    tight.rlim_cur = std::min(tight.rlim_cur, saved.rlim_max);
-    ASSERT_EQ(setrlimit(RLIMIT_NOFILE, &tight), 0);
-
-    // Open /dev/null until the table is full: every free fd below the limit 
is now
-    // taken, so the next ::open (the spill's) cannot get one -> EMFILE.
-    std::vector<int> hogs;
-    for (;;) {
-        int fd = ::open("/dev/null", O_RDONLY);
-        if (fd < 0) {
-            break; // table exhausted
-        }
-        hogs.push_back(fd);
-    }
-    ASSERT_FALSE(hogs.empty());
+    {
+        std::vector<int> hogs;
+        // Restore on EVERY exit path, including a fatal assertion below. 
Leaving the
+        // process at the tight limit would break every later test in the 
binary, and
+        // gtest's death tests would abort the whole run when their pipe() 
hits EMFILE.
+        doris::Defer restore([&] {
+            for (int fd : hogs) {
+                ::close(fd);
+            }
+            EXPECT_EQ(setrlimit(RLIMIT_NOFILE, &saved), 0);
+        });
 
-    buf.add_token("z", 0, 0); // triggers a spill whose RunWriter::open must 
fail
+        // Cap just above the descriptors this process already holds instead 
of a fixed
+        // number: how many are open depends on which tests ran first, and a 
cap below
+        // that leaves no free descriptor for the hog loop to take. dup() 
returns the
+        // lowest free descriptor, so every number under the cap is still 
available.
+        const int lowest_free = ::dup(STDIN_FILENO);
+        ASSERT_GE(lowest_free, 0);
+        ASSERT_EQ(::close(lowest_free), 0);
+        struct rlimit tight = saved;
+        tight.rlim_cur = std::min<rlim_t>(static_cast<rlim_t>(lowest_free) + 
8, saved.rlim_max);
+        ASSERT_EQ(setrlimit(RLIMIT_NOFILE, &tight), 0);
+
+        // Open /dev/null until the table is full: every free fd below the 
limit is now
+        // taken, so the next ::open (the spill's) cannot get one -> EMFILE.
+        for (;;) {
+            int fd = ::open("/dev/null", O_RDONLY);
+            if (fd < 0) {
+                break; // table exhausted
+            }
+            hogs.push_back(fd);
+        }
+        ASSERT_FALSE(hogs.empty());
 
-    // Release the hog fds and restore the limit before asserting (so gtest 
I/O works).
-    for (int fd : hogs) {
-        ::close(fd);
+        buf.add_token("z", 0, 0); // triggers a spill whose RunWriter::open 
must fail
     }
-    ASSERT_EQ(setrlimit(RLIMIT_NOFILE, &saved), 0);
-
+    // The hog fds are released and the limit restored here, before asserting, 
so gtest
+    // I/O works.
     EXPECT_FALSE(buf.status().ok()) << "spill open() failure must latch an 
error";
 }
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to