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

gavinchou 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 76bd6f0a138 [fix](rle) Fix infinite loop when a repeated run reaches 
2^30 values (#69100)
76bd6f0a138 is described below

commit 76bd6f0a138a17dde4b4b8123038ca5c4679a2c9
Author: Xin Liao <[email protected]>
AuthorDate: Fri Oct 16 16:20:57 2026 +0800

    [fix](rle) Fix infinite loop when a repeated run reaches 2^30 values 
(#69100)
    
    ### What problem does this PR solve?
    
    Problem Summary:
    
    `RleEncoder::FlushRepeatedRun()` wrote `repeat_count_ << 1` into an
    `int32_t`. `repeat_count_` was an `int`, so a repeated run of 2^30 or
    more values overflowed the indicator into a negative number.
    `BitWriter::PutVlqInt()` then took that as an `int32_t` and shifted it
    right arithmetically:
    
    ```cpp
    inline void BitWriter::PutVlqInt(int32_t v) {
        while ((v & 0xFFFFFF80) != 0L) {
            PutAligned<uint8_t>((v & 0x7F) | 0x80, 1);
            v >>= 7;
        }
        PutAligned<uint8_t>(v & 0x7F, 1);
    }
    ```
    
    An arithmetic right shift of a negative value converges to `-1` and
    stays there, and `-1 & 0xFFFFFF80` is non-zero, so **the loop never
    exits**. It appends `0xFF` to the output buffer forever, pinning a core
    at 100% and growing the buffer without bound.
    
    #### How it was hit
    
    A base compaction in a production cluster wedged on a table with many
    nullable `text` columns. Two `BaseCompactionT` threads sat in this stack
    for over a month:
    
    ```
    #0  inline_memcpy (size=348057208)
    #2  doris::faststring::GrowArray (newcapacity=502825728)
    #4  doris::BitWriter::GetNextBytePtr (num_bytes=1)
    #5  doris::BitWriter::PutAligned<unsigned char> (val=255 '\377')
    #6  doris::BitWriter::PutVlqInt (v=-1)              
bit_stream_utils.inline.h:91
    #7  doris::RleEncoder<bool>::FlushRepeatedRun       rle_encoding.h:499
    #9  doris::segment_v2::NullBitmapBuilder::finish
    #11 doris::segment_v2::ScalarColumnWriter::finish
    #15 doris::Merger::vertical_compact_one_group (is_key=false, 
max_rows_per_segment=1073741824)
    #20 doris::CloudBaseCompaction::execute_compact
    ```
    
    `v=-1` together with `val=255` pins it down exactly: the loop is
    emitting `0xFF` with `v` stuck at `-1`.
    
    Two existing behaviours combined to produce a 2^30-value run:
    
    - Vertical compaction derives the segment row limit from
    `vertical_compaction_max_segment_size / avg_bytes_per_row`
    
([`Compaction::get_avg_segment_rows()`](https://github.com/apache/doris/blob/master/be/src/olap/compaction.cpp)).
    For this table the average row size integer-divided to `0`, so the
    divisor degenerated to `1` and the limit became the config default
    `1073741824` = 2^30 rows — visible as `max_rows_per_segment=1073741824`
    in the stack above.
    - `ScalarColumnWriter::append_nulls()` never checks whether the page is
    full, unlike `append_data()`, and NULLs never reach the page builder. So
    an all-NULL column accumulated a single null-bitmap run spanning the
    whole 2^30-row segment in one page.
    
    Because the stuck tasks kept the base compaction slots occupied
    (`max_base_compaction_task_num_per_disk` defaults to 2),
    `_generate_cloud_compaction_tasks()` computed `base_n = 0` and stopped
    picking any tablet, so **no base compaction ran on that backend again**
    and compaction scores climbed to the cap across the instance.
    
    ### What this PR changes
    
    1. **`PutVlqInt()` takes `uint32_t`.** A logical shift always converges
    to 0, so the loop terminates for every possible input. This is the
    backstop that makes a hang impossible regardless of what callers pass.
    
    2. **`repeat_count_` widened to `int64_t`, and long runs are split.**
    Runs longer than `MAX_VALUES_PER_REPEATED_RUN` (2^30 - 1) are emitted as
    several back-to-back repeated runs of the same value. Readers already
    merge adjacent runs carrying the same value (`RleDecoder::GetNextRun`),
    so splitting is invisible to them. The cap keeps the indicator inside
    `int32_t`'s positive range — exactly the range previous versions could
    encode — so **output stays byte-identical for every run length that used
    to work**, and no reader change is needed.
    
    3. **`faststring::reserve()` grows geometrically** via the existing
    `GrowToAtLeast()` instead of reallocating to exactly the requested
    capacity. `BitWriter` reserves a handful of bytes at a time, so exact
    growth reallocated and memcpy'd the entire buffer every 8 bytes, making
    an n-byte append cost O(n^2). This is what turned the infinite loop into
    a thread stuck in `memcpy` with a ~500MB buffer, and it slows down every
    other large RLE buffer as well. Measured: reaching 1 MiB now takes 26
    reallocations instead of ~131,000.
    
    Deliberately **not** in this PR, each worth its own change:
    - `ScalarColumnWriter::append_nulls()` still lets a page grow without
    bound.
    - `Compaction::get_avg_segment_rows()` still degenerates to the
    configured byte size when the average row size rounds down to zero.
    
    ### Release note
    
    Fix a BE hang in RLE encoding when a column produces a run of 2^30 or
    more identical values (for example an all-NULL column in a very large
    segment), which could permanently stall base compaction on that backend.
    
    ### Check List (For Author)
    
    - Test
        - [x] Unit Test
    
    New tests:
    
    | Test | Covers |
    |---|---|
    | `TestRle.TestRepeatedRunExceedingIndicatorRange` | Run lengths around
    and far beyond 2^30 encode compactly and decode back to the original
    length |
    | `TestBitStreamUtil.TestPutVlqIntRoundTrip` | `PutVlqInt` round-trips
    every `uint32_t`, including values `>= 2^31` that used to hang |
    | `FaststringTest.TestReserve_ExponentiallyExpand` | `reserve()` does
    not reallocate on every call |
    
    Verified that the new tests actually catch the bug: built the exact
    repro (`RleEncoder<bool>::Put(true, 1 << 30)` followed by `Flush()`)
    against unpatched master and it never returns (killed by timeout); with
    this patch it completes and emits 8 bytes. All pre-existing tests in
    `rle_encoding_test`, `bit_stream_utils_test` and `faststring_test` pass
    (29 tests, with `LOOP_LESS_OR_MORE` forced to the slow/thorough branch).
    
    Also confirmed no format regression: a short run encoded via `Put(v)` in
    a loop and via `Put(v, n)` in one call still produce byte-identical
    output.
    
    - Behavior changed:
    - [x] No. Encoded output is unchanged for every run length that
    previously encoded correctly; only run lengths that used to hang now
    produce (correct, readable) output.
    
    - Does this need documentation?
        - [x] No.
    
    Co-authored-by: Xin Liao <[email protected]>
---
 be/src/util/bit_stream_utils.h         |  4 ++-
 be/src/util/bit_stream_utils.inline.h  |  8 +++---
 be/src/util/faststring.h               |  5 +++-
 be/src/util/rle_encoding.h             | 36 +++++++++++++++++++-----
 be/test/util/bit_stream_utils_test.cpp | 25 +++++++++++++++++
 be/test/util/faststring_test.cpp       | 24 ++++++++++++++++
 be/test/util/rle_encoding_test.cpp     | 50 ++++++++++++++++++++++++++++++++++
 7 files changed, 139 insertions(+), 13 deletions(-)

diff --git a/be/src/util/bit_stream_utils.h b/be/src/util/bit_stream_utils.h
index fbaa219d012..270a0635ed3 100644
--- a/be/src/util/bit_stream_utils.h
+++ b/be/src/util/bit_stream_utils.h
@@ -59,7 +59,9 @@ public:
 
     // Write a Vlq encoded int to the buffer. The value is written byte 
aligned.
     // For more details on vlq: en.wikipedia.org/wiki/Variable-length_quantity
-    void PutVlqInt(int32_t v);
+    // Takes an unsigned value on purpose: the encoding shifts `v` right until 
it
+    // is exhausted, which only terminates for a logical shift.
+    void PutVlqInt(uint32_t v);
 
     // Get the index to the next aligned byte and advance the underlying 
buffer by num_bytes.
     size_t GetByteIndexAndAdvance(int num_bytes) {
diff --git a/be/src/util/bit_stream_utils.inline.h 
b/be/src/util/bit_stream_utils.inline.h
index 2c3ceb79e44..f24e8ce90f2 100644
--- a/be/src/util/bit_stream_utils.inline.h
+++ b/be/src/util/bit_stream_utils.inline.h
@@ -85,12 +85,12 @@ void BitWriter::PutAligned(T val, int num_bytes) {
     memcpy(ptr, &val, num_bytes);
 }
 
-inline void BitWriter::PutVlqInt(int32_t v) {
-    while ((v & 0xFFFFFF80) != 0L) {
-        PutAligned<uint8_t>((v & 0x7F) | 0x80, 1);
+inline void BitWriter::PutVlqInt(uint32_t v) {
+    while ((v & 0xFFFFFF80U) != 0U) {
+        PutAligned<uint8_t>(static_cast<uint8_t>((v & 0x7F) | 0x80), 1);
         v >>= 7;
     }
-    PutAligned<uint8_t>(v & 0x7F, 1);
+    PutAligned<uint8_t>(static_cast<uint8_t>(v & 0x7F), 1);
 }
 
 inline BitReader::BitReader(const uint8_t* buffer, int buffer_len)
diff --git a/be/src/util/faststring.h b/be/src/util/faststring.h
index 738444553d4..4e2a4c5d74f 100644
--- a/be/src/util/faststring.h
+++ b/be/src/util/faststring.h
@@ -106,7 +106,10 @@ public:
         if (newcapacity <= capacity_) [[likely]] {
             return;
         }
-        GrowArray(newcapacity);
+        // Grow by at least 50% rather than to exactly `newcapacity`. Callers 
such
+        // as BitWriter reserve a few bytes at a time, and reallocating on 
every
+        // call makes appending n bytes cost O(n^2) in memcpy.
+        GrowToAtLeast(newcapacity);
     }
 
     // Append the given data to the string, resizing capacity as necessary.
diff --git a/be/src/util/rle_encoding.h b/be/src/util/rle_encoding.h
index f96d00e7941..18442456c49 100644
--- a/be/src/util/rle_encoding.h
+++ b/be/src/util/rle_encoding.h
@@ -18,6 +18,8 @@
 
 #include <glog/logging.h>
 
+#include <algorithm>
+#include <cstdint>
 #include <limits> // IWYU pragma: keep
 
 #include "common/cast_set.h"
@@ -156,6 +158,16 @@ public:
         Clear();
     }
 
+    // The largest number of values a single repeated run can describe.
+    //
+    // A repeated run is written as a vlq-encoded indicator holding
+    // `run_length << 1` (see FlushRepeatedRun) and read back into a uint32_t 
by
+    // BitReader::GetVlqInt. Capping the run length here keeps the indicator
+    // inside int32_t's positive range, which is exactly the range every 
released
+    // version could already encode, so the output stays byte-identical for any
+    // run length that used to work.
+    static constexpr int64_t MAX_VALUES_PER_REPEATED_RUN = (int64_t {1} << 30) 
- 1;
+
     // Reserve 'num_bytes' bytes for a plain encoded header, set each
     // byte with 'val': this is used for the RLE-encoded data blocks in
     // order to be able to able to store the initial ordinal position
@@ -212,7 +224,7 @@ private:
     // if we are in a literal run.  If the repeat_count_ get high enough, we 
switch
     // to encoding repeated runs.
     uint64_t current_value_;
-    int repeat_count_;
+    int64_t repeat_count_;
 
     // Number of literals in the current run.  This does not include the 
literals
     // that might be in buffered_values_.  Only after we've got a group big 
enough
@@ -437,7 +449,7 @@ void RleEncoder<T>::Put(T value, size_t run_length) {
     // Fast path: if this is a continuation of the current repeated run and
     // we've already buffered enough values, just increment repeat_count_
     if (current_value_ == value && repeat_count_ >= 8 && run_length > 0) 
[[likely]] {
-        repeat_count_ += run_length;
+        repeat_count_ += static_cast<int64_t>(run_length);
         return;
     }
 
@@ -457,7 +469,7 @@ void RleEncoder<T>::Put(T value, size_t run_length) {
                 // After flushing, if we still have a repeated run and more 
values,
                 // we can add them directly to repeat_count_
                 if (repeat_count_ >= 8 && run_length > 0) {
-                    repeat_count_ += run_length;
+                    repeat_count_ += static_cast<int64_t>(run_length);
                     return;
                 }
             }
@@ -514,11 +526,21 @@ void RleEncoder<T>::FlushLiteralRun(bool 
update_indicator_byte) {
 template <typename T>
 void RleEncoder<T>::FlushRepeatedRun() {
     DCHECK_GT(repeat_count_, 0);
-    // The lsb of 0 indicates this is a repeated run
-    int32_t indicator_value = repeat_count_ << 1 | 0;
-    bit_writer_.PutVlqInt(indicator_value);
-    bit_writer_.PutAligned(current_value_, BitUtil::Ceil(bit_width_, 8));
+    // A run longer than MAX_VALUES_PER_REPEATED_RUN cannot be described by a
+    // single indicator, so emit it as several back-to-back repeated runs of 
the
+    // same value. Decoders stitch those back together transparently
+    // (RleDecoder::GetNextRun merges adjacent runs carrying the same value), 
so
+    // splitting is invisible to readers.
+    while (repeat_count_ > 0) {
+        const int64_t run_length = std::min(repeat_count_, 
MAX_VALUES_PER_REPEATED_RUN);
+        // The lsb of 0 indicates this is a repeated run
+        bit_writer_.PutVlqInt(static_cast<uint32_t>(run_length) << 1);
+        bit_writer_.PutAligned(current_value_, BitUtil::Ceil(bit_width_, 8));
+        repeat_count_ -= run_length;
+    }
     num_buffered_values_ = 0;
+    // The loop above drains repeat_count_; assign it anyway so the 
postcondition
+    // holds even if it somehow entered non-positive.
     repeat_count_ = 0;
 }
 
diff --git a/be/test/util/bit_stream_utils_test.cpp 
b/be/test/util/bit_stream_utils_test.cpp
index 3a078f004c0..55976ce4ee3 100644
--- a/be/test/util/bit_stream_utils_test.cpp
+++ b/be/test/util/bit_stream_utils_test.cpp
@@ -218,4 +218,29 @@ TEST(TestBitStreamUtil, TestUint64) {
     reader.GetValue(16, &v4);
     EXPECT_EQ(v4, 126);
 }
+// PutVlqInt() must terminate for every uint32_t. It used to take an int32_t 
and
+// shift it right arithmetically, so any value with the sign bit set (>= 2^31)
+// made the loop spin forever appending 0xFF.
+TEST(TestBitStreamUtil, TestPutVlqIntRoundTrip) {
+    const int max_vlq_byte_len = BitReader::MAX_VLQ_BYTE_LEN;
+    const std::vector<uint32_t> values = {
+            0,          1,          0x7F,       0x80,       0x3FFF,     0x4000,
+            0x1FFFFF,   0x200000,   0xFFFFFFF,  0x10000000, 0x7FFFFFFE, 
0x7FFFFFFF,
+            0x80000000, 0x80000001, 0xFFFFFFFE, 0xFFFFFFFF,
+    };
+
+    for (uint32_t value : values) {
+        faststring buffer;
+        BitWriter writer(&buffer);
+        writer.PutVlqInt(value);
+        writer.Flush();
+        EXPECT_LE(writer.bytes_written(), max_vlq_byte_len) << "value=" << 
value;
+
+        BitReader reader(buffer.data(), buffer.size());
+        uint32_t decoded = 0;
+        ASSERT_TRUE(reader.GetVlqInt(&decoded)) << "value=" << value;
+        EXPECT_EQ(value, decoded) << "value=" << value;
+    }
+}
+
 } // namespace doris
diff --git a/be/test/util/faststring_test.cpp b/be/test/util/faststring_test.cpp
index 1abcd7e97fb..ea708337ab2 100644
--- a/be/test/util/faststring_test.cpp
+++ b/be/test/util/faststring_test.cpp
@@ -135,4 +135,28 @@ TEST_F(FaststringTest, TestAppend_ExponentiallyExpand) {
     }
 }
 
+// reserve() must grow geometrically. BitWriter reserves a handful of bytes at 
a
+// time, so reallocating to the exact requested size made appending n bytes 
cost
+// O(n^2) in memcpy -- enough to stall a thread outright once the buffer 
reached
+// hundreds of megabytes.
+TEST_F(FaststringTest, TestReserve_ExponentiallyExpand) {
+    constexpr size_t kTargetSize = 1 * 1024 * 1024;
+
+    faststring s;
+    size_t last_capacity = s.capacity();
+    int reallocations = 0;
+    for (size_t i = 0; i < kTargetSize; ++i) {
+        s.reserve(i + 1);
+        if (s.capacity() != last_capacity) {
+            ++reallocations;
+            last_capacity = s.capacity();
+        }
+    }
+
+    EXPECT_GE(s.capacity(), kTargetSize);
+    // Growing 1.5x at a time from kInitialCapacity needs ~26 steps to pass 1 
MiB.
+    // Exact growth would instead reallocate on (almost) every one of the 1M 
calls.
+    EXPECT_LT(reallocations, 64);
+}
+
 } // namespace doris
diff --git a/be/test/util/rle_encoding_test.cpp 
b/be/test/util/rle_encoding_test.cpp
index 5af34168869..59b9368edce 100644
--- a/be/test/util/rle_encoding_test.cpp
+++ b/be/test/util/rle_encoding_test.cpp
@@ -698,6 +698,56 @@ TEST_F(TestRle, TestPutFlushThenFastPath) {
     }
 }
 
+// Regression test for a hang on repeated runs that a single run indicator
+// cannot describe.
+//
+// FlushRepeatedRun() used to write `repeat_count_ << 1` into an int32_t, so a
+// run of 2^30 or more values overflowed into a negative indicator.
+// BitWriter::PutVlqInt() then took an int32_t and shifted it right
+// arithmetically, which sticks at -1 instead of reaching 0 -- an infinite loop
+// appending 0xFF to the output buffer. In the wild this showed up as a base
+// compaction thread spinning forever while flushing the null bitmap of a
+// nullable column whose page held one run of 2^30 null rows.
+TEST_F(TestRle, TestRepeatedRunExceedingIndicatorRange) {
+    constexpr int64_t kMaxRun = RleEncoder<bool>::MAX_VALUES_PER_REPEATED_RUN;
+    const std::vector<int64_t> run_lengths = {
+            kMaxRun - 1,
+            kMaxRun,
+            kMaxRun + 1,
+            int64_t {1} << 30, // smallest run length that used to overflow
+            (int64_t {1} << 30) + 12345,
+            int64_t {1} << 31,
+            3 * (int64_t {1} << 30),
+    };
+
+    for (int64_t run_length : run_lengths) {
+        faststring buffer;
+        RleEncoder<bool> encoder(&buffer, 1);
+        // Put() extends an established run in O(1), so this stays cheap even 
for
+        // billions of values.
+        encoder.Put(true, run_length);
+        const int encoded_len = encoder.Flush();
+
+        // A split run stays compact: one short header per emitted run.
+        const int64_t expected_runs = (run_length + kMaxRun - 1) / kMaxRun;
+        EXPECT_LE(encoded_len, expected_runs * 8 + 8) << "run_length=" << 
run_length;
+
+        // GetNextRun() merges adjacent repeated runs carrying the same value, 
so
+        // splitting is invisible to readers: this must read back as one run of
+        // the original length.
+        RleDecoder<bool> decoder(buffer.data(), encoded_len, 1);
+        int64_t decoded = 0;
+        while (decoded < run_length) {
+            bool val = false;
+            size_t run = decoder.GetNextRun(&val, 
static_cast<size_t>(run_length - decoded));
+            ASSERT_GT(run, 0u) << "run_length=" << run_length << " decoded=" 
<< decoded;
+            ASSERT_TRUE(val) << "run_length=" << run_length << " decoded=" << 
decoded;
+            decoded += static_cast<int64_t>(run);
+        }
+        EXPECT_EQ(decoded, run_length) << "run_length=" << run_length;
+    }
+}
+
 // Benchmark for Put function performance with consecutive equal values
 // This test compares:
 // 1. Put(value) called N times in a loop


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

Reply via email to