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

yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new 2c8d7c1f347 branch-4.1: [fix](inverted index) Persist empty index 
files and stop writing orphan ones #67859 (#68082)
2c8d7c1f347 is described below

commit 2c8d7c1f347073686df7871e54760b9ab207b763
Author: Jack <[email protected]>
AuthorDate: Sun Sep 20 10:00:15 2026 +0800

    branch-4.1: [fix](inverted index) Persist empty index files and stop 
writing orphan ones #67859 (#68082)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A
    
    Related PR: https://github.com/apache/doris/pull/67859
    
    Problem Summary:
    
    This backports source commit `8d9e9c3f6d0ff2186a6ac52bb572a6fbee457938`
    to `branch-4.1`.
    
    An all-NULL VARIANT column can legitimately produce a zero-byte V2/V3
    index container. The old empty-file path closed only selected writer
    implementations, so a local writer could abort and delete the file
    during destruction. `IndexBuilder` also rejected an empty source
    container and could write an orphan container after the output schema
    lost its last index.
    
    The backport:
    
    1. Closes empty containers through the `FileWriter` interface while
    preserving the two-phase close protocol and error propagation.
    2. Treats an empty source index container like a missing one when there
    is nothing to carry over.
    3. Avoids writing an index container when the output rowset schema owns
    no inverted or ANN index.
    
    `branch-4.1` has no SNII format, so the source PR's SNII dispatch and
    SNII-only unit test are not applicable. The V2/V3 behavior uses this
    branch's split `has_inverted_index()` / `has_ann_index()` predicates and
    older block APIs.
    
    ### Release note
    
    Persist legitimate empty V2/V3 inverted-index files, allow index
    rebuilds to consume them, and avoid orphan index files after the last
    index is gone.
    
    ### Check List (For Author)
    
    - Test
        - [x] Regression test
        - [x] Unit Test
        - [ ] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    - [ ] This is a refactor/code format and no logic has been changed.
            - [ ] Previous test can cover this change.
            - [ ] No code files have been changed.
            - [ ] Other reason
    
    - Behavior changed:
        - [ ] No.
    - [x] Yes. Empty index containers are persisted and accepted, while
    schema-invisible orphan containers are no longer written.
    
    - Does this need documentation?
        - [x] No. This restores the existing rowset/index-file invariant.
        - [ ] Yes.
    
    ### Validation
    
    - `BUILD_TYPE=ASAN ./build.sh --be -j32`: passed.
    - `./build.sh --fe -j16`: passed, including Checkstyle.
    - Focused ASAN BE unit-test filter covering 18 parameterized
    `EmptyIndexFileTest` cases, five `IndexBuilderTest` cases, the
    null-array VARIANT case, and the S3 empty-file case: 25/25 passed twice,
    with no sanitizer report.
    - `test_empty_index_file_lifecycle`: passed 1/1 on an isolated classic
    cluster.
    - `test_variant_empty_index_file`: passed 1/1 on the same cluster.
    - Clang-format 16 selected-file check and full merge-tree preflight:
    passed.
    - PR English check across all eight changed source files: passed with no
    exceptions.
    - Targeted changed-line clang-tidy checks: passed. A full changed-file
    run is limited by the pre-existing unmatched `NOLINTEND` in
    `be/src/core/types.h` and legacy whole-function diagnostics in
    branch-4.1; no new changed-line diagnostic remains.
    
    ### Backport audit
    
    `git range-diff 8d9e9c3^! origin/branch-4.1...HEAD` was reviewed.
    Positional differences come from the older branch layout and APIs. The
    two regression-test file patches have stable patch IDs identical to the
    source. Every source hunk is accounted for below.
    
    | Source file | Source hunk | Disposition |
    |---|---|---|
    | `be/src/storage/index/index_file_writer.cpp` | `@@ -30 +29,0 @@` |
    Ported: remove the now-unneeded concrete S3 writer include. |
    | same | `@@ -511,4 +510,10 @@ Status IndexFileWriter::begin_close()` |
    Ported: close every non-null, non-closed `FileWriter`; the branch has no
    preceding SNII path. |
    | same | `@@ -559,4 +564,5 @@ Status IndexFileWriter::finish_close()` |
    Ported: preserve asynchronous finish and idempotently skip an already
    closed writer. |
    | `be/src/storage/task/index_builder.cpp` | `@@ -389 +389,8 @@ Status
    IndexBuilder::update_inverted_index_info()` | Adapted: accept
    `INVERTED_INDEX_BYPASS` exactly like missing input; comments use branch
    terminology. |
    | same | `@@ -449,2 +456,17 @@ Status
    IndexBuilder::handle_single_rowset(RowsetMetaSharedPtr
    output_rowset_meta` | Adapted: gate the drop rewrite with
    `has_inverted_index() || has_ann_index()` because this branch lacks the
    combined helper and SNII. |
    | same | `@@ -519 +540,0 @@ Status
    IndexBuilder::handle_single_rowset(RowsetMetaSharedPtr
    output_rowset_meta` | Adapted: defer filesystem resolution until after
    the no-index early return. |
    | same | `@@ -520,0 +542,12 @@ Status
    IndexBuilder::handle_single_rowset(RowsetMetaSharedPtr
    output_rowset_meta` | Adapted: add the same no-index output gate with
    the branch's split predicates. |
    | same | `@@ -524,0 +558,3 @@ Status
    IndexBuilder::handle_single_rowset(RowsetMetaSharedPtr
    output_rowset_meta` | Adapted: resolve `fs()` only for the V2/V3 path
    that will write a file; there is no SNII dispatch here. |
    | `be/test/io/fs/s3_file_writer_test.cpp` | `@@ -1533 +1533 @@
    TEST_F(S3FileWriterTest, test_empty_file)` | Ported; also scopes
    `enable_file_cache=false` because the branch-4.1 fixture otherwise
    reaches an uninitialized cache factory. |
    | same | `@@ -1537,0 +1538,2 @@ TEST_F(S3FileWriterTest,
    test_empty_file)` | Ported: retain the writer pointer and object path
    for lifecycle/object assertions. |
    | same | `@@ -1545,2 +1547,14 @@ TEST_F(S3FileWriterTest,
    test_empty_file)` | Ported: verify two-phase close, idempotence, one
    zero-byte PUT, and no multipart upload. |
    | `be/test/storage/index/index_builder_test.cpp` | `@@ -23,0 +24 @@` |
    Ported: add the set container used to inspect output index IDs. |
    | same | `@@ -536,0 +538,123 @@ protected:` | Adapted: use branch-4.1
    `create_block()` and scoped column mutation; preserve empty-vs-missing
    source coverage. Specific complexity suppressions document the
    assertion-heavy test helper. |
    | same | `@@ -709 +833,3 @@ TEST_F(IndexBuilderTest,
    DropInvertedIndexTest)` | Adapted: preserve the branch fixture while
    asserting that dropping the last index leaves no `.idx` file. |
    | same | `@@ -1027,0 +1154,20 @@ TEST_F(IndexBuilderTest,
    BuildInvertedIndexAfterWritingDataTest)` | Ported: build over both an
    empty and a missing source container and verify the requested output
    index. |
    | same | `@@ -3509,0 +3656,22 @@ TEST_F(IndexBuilderTest,
    DropOneIndexNotAffectOtherIndexesOnSameColumnTest)` | N/A: this is the
    source SNII-only no-surviving-index test; branch-4.1 defines neither the
    SNII storage format nor its builder path. The equivalent V2/V3 no-index
    behavior is covered by `NonExistentColumnIndexTest` and the drop tests.
    |
    | `be/test/storage/index/inverted/empty_index_file_test.cpp` | `@@ -22,0
    +23 @@` | Adapted: add the branch's local filesystem header for
    persistence checks. |
    | same | `@@ -23,0 +25 @@` | Ported: add `IndexFileReader` for
    empty/missing error-code assertions. |
    | same | `@@ -25,0 +28 @@` | Ported: add the inverted-index descriptor
    used to resolve the compound-file path. |
    | same | `@@ -33 +36 @@ constexpr int64_t NUM_STREAM = 3;` | Adapted:
    convert the older non-parameterized fixture to V2/V3 parameterized
    coverage and retain the branch mock-stream baseline. |
    | same | `@@ -76,0 +80,52 @@ protected:` | Ported into the older
    fixture: an opaque `FileWriter` records begin/finish close state and
    failures without relying on a concrete writer type. |
    | same | `@@ -104,9 +159,11 @@ TEST_P(EmptyIndexFileTest,
    PreservesZeroByteFileWhenNoLogicalIndexes) {` | Adapted: initialize the
    branch's `StreamSinkFileWriter` and verify one EOS per stream with no
    data. |
    | same | `@@ -119,0 +177,117 @@ TEST_P(EmptyIndexFileTest,
    PreservesZeroByteFileWhenNoLogicalIndexes) {` | Ported into the older
    fixture: generic writer lifecycle/error/null tests plus local
    persistence and empty-vs-missing reader contracts for V2/V3. |
    | `be/test/storage/variant/index_storage_variant_debug_point_test.cpp` |
    `@@ -150,5 +150,2 @@ protected:` | N/A as a behavior change: branch-4.1
    already expected an index file for the null-array case. The
    branch-specific debug-point expectation is preserved and an explanatory
    comment was added. |
    |
    
`regression-test/suites/inverted_index_p0/test_empty_index_file_lifecycle.groovy`
    | Entire new-file hunk `@@ -0,0 +1,178 @@` | Ported verbatim; stable
    patch ID `557150e08942a2234b48414952ab35eaaf718596`. |
    |
    
`regression-test/suites/inverted_index_p0/test_variant_empty_index_file.groovy`
    | All four source hunks | Ported verbatim; stable patch ID
    `d701fcc160d6289125fd56f93fee10c669487c24`. |
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 be/src/storage/index/index_file_writer.cpp         |  24 +-
 be/src/storage/task/index_builder.cpp              |  33 ++-
 be/test/io/fs/s3_file_writer_test.cpp              |  24 +-
 be/test/storage/index/index_builder_test.cpp       | 153 ++++++++++++-
 .../index/inverted/empty_index_file_test.cpp       | 253 +++++++++++++++++++--
 .../index_storage_variant_debug_point_test.cpp     |   1 +
 .../test_empty_index_file_lifecycle.groovy         | 178 +++++++++++++++
 .../test_variant_empty_index_file.groovy           |  95 ++++----
 8 files changed, 682 insertions(+), 79 deletions(-)

diff --git a/be/src/storage/index/index_file_writer.cpp 
b/be/src/storage/index/index_file_writer.cpp
index 3c34326cd88..6ee16f2819b 100644
--- a/be/src/storage/index/index_file_writer.cpp
+++ b/be/src/storage/index/index_file_writer.cpp
@@ -24,7 +24,6 @@
 
 #include "common/status.h"
 #include "io/fs/packed_file_writer.h"
-#include "io/fs/s3_file_writer.h"
 #include "io/fs/stream_sink_file_writer.h"
 #include "storage/index/ann/ann_index_files.h"
 #include "storage/index/index_file_reader.h"
@@ -198,10 +197,16 @@ Status IndexFileWriter::begin_close() {
     DCHECK(!_closed) << debug_string();
     _closed = true;
     if (_indices_dirs.empty()) {
-        // An empty file must still be created even if there are no indexes to 
write
-        if (dynamic_cast<io::StreamSinkFileWriter*>(_idx_v2_writer.get()) != 
nullptr ||
-            dynamic_cast<io::S3FileWriter*>(_idx_v2_writer.get()) != nullptr ||
-            dynamic_cast<io::PackedFileWriter*>(_idx_v2_writer.get()) != 
nullptr) {
+        // A schema that owns an index file always gets one, even when no 
logical
+        // index had anything to write (an all-NULL VARIANT column extracts no
+        // subcolumn, so no directory is ever opened). The file is committed by
+        // close(), not by create_file(): S3 turns a zero-byte writer into an 
empty
+        // object, StreamSink sends segment_eos, and LocalFileWriter's 
destructor
+        // ABORTS -- and deletes -- a writer it was never asked to close. 
Dispatch
+        // through FileWriter rather than naming implementations: the old 
whitelist
+        // silently dropped LocalFileWriter and HdfsFileWriter, and every new
+        // implementation would have had to remember to add itself here.
+        if (_idx_v2_writer != nullptr && _idx_v2_writer->state() != 
io::FileWriter::State::CLOSED) {
             return _idx_v2_writer->close(true);
         }
         return Status::OK();
@@ -240,10 +245,11 @@ Status IndexFileWriter::begin_close() {
 Status IndexFileWriter::finish_close() {
     DCHECK(_closed) << debug_string();
     if (_indices_dirs.empty()) {
-        // An empty file must still be created even if there are no indexes to 
write
-        if (dynamic_cast<io::StreamSinkFileWriter*>(_idx_v2_writer.get()) != 
nullptr ||
-            dynamic_cast<io::S3FileWriter*>(_idx_v2_writer.get()) != nullptr ||
-            dynamic_cast<io::PackedFileWriter*>(_idx_v2_writer.get()) != 
nullptr) {
+        // Second phase of the empty-file close begun in begin_close(). 
Skipping an
+        // already CLOSED writer keeps this idempotent: begin_close() may have
+        // closed synchronously, and a retried finish_close() must not send a
+        // second EOS or PUT a second empty object.
+        if (_idx_v2_writer != nullptr && _idx_v2_writer->state() != 
io::FileWriter::State::CLOSED) {
             return _idx_v2_writer->close(false);
         }
         return Status::OK();
diff --git a/be/src/storage/task/index_builder.cpp 
b/be/src/storage/task/index_builder.cpp
index 172b868efc2..8d8811b4b6f 100644
--- a/be/src/storage/task/index_builder.cpp
+++ b/be/src/storage/task/index_builder.cpp
@@ -286,7 +286,12 @@ Status IndexBuilder::update_inverted_index_info() {
                             st = Status::Error<ErrorCode::INIT_FAILED>(
                                     "debug point: reader init error");
                         })
-                if (!st.ok() && 
!st.is<ErrorCode::INVERTED_INDEX_FILE_NOT_FOUND>()) {
+                // A missing container (the rowset predates every index) and an
+                // empty one (the schema owns an index, but no logical index 
had
+                // anything to write) both mean there is nothing to carry over.
+                // In both cases every requested index is built from the raw 
columns.
+                if (!st.ok() && 
!st.is<ErrorCode::INVERTED_INDEX_FILE_NOT_FOUND>() &&
+                    !st.is<ErrorCode::INVERTED_INDEX_BYPASS>()) {
                     return st;
                 }
                 _index_file_readers.emplace(
@@ -339,8 +344,19 @@ Status 
IndexBuilder::handle_single_rowset(RowsetMetaSharedPtr output_rowset_meta
 
     if (_is_drop_op) {
         const auto& output_rs_tablet_schema = 
output_rowset_meta->tablet_schema();
-        if (output_rs_tablet_schema->get_inverted_index_storage_format() !=
-            InvertedIndexStorageFormatPB::V1) {
+        // A rowset must not keep an index file that its own schema does not 
claim:
+        // link, copy, upload, remove and checksum all consult that schema 
before
+        // touching the compound file. The old LocalFileWriter destructor 
happened
+        // to delete an unclosed orphan, but remote writers could preserve it.
+        const bool is_v1 = 
output_rs_tablet_schema->get_inverted_index_storage_format() ==
+                           InvertedIndexStorageFormatPB::V1;
+        const bool output_has_index_file = 
output_rs_tablet_schema->has_inverted_index() ||
+                                           
output_rs_tablet_schema->has_ann_index();
+        if (!is_v1 && !output_has_index_file) {
+            LOG(INFO) << "drop index removed the last index, no index file is 
written. tablet_id="
+                      << _tablet->tablet_id()
+                      << " rowset_id=" << 
output_rowset_meta->rowset_id().to_string();
+        } else if (!is_v1) {
             const auto& fs = output_rowset_meta->fs();
 
             const auto& output_rowset_schema = 
output_rowset_meta->tablet_schema();
@@ -409,8 +425,17 @@ Status 
IndexBuilder::handle_single_rowset(RowsetMetaSharedPtr output_rowset_meta
         return Status::OK();
     } else {
         // create inverted or ann index writer
-        const auto& fs = output_rowset_meta->fs();
         auto output_rowset_schema = output_rowset_meta->tablet_schema();
+        // If no requested index survives schema resolution and the input 
rowset
+        // owned none, the output schema must not gain an orphan compound file.
+        if (!output_rowset_schema->has_inverted_index() && 
!output_rowset_schema->has_ann_index()) {
+            LOG(INFO) << "no index in the output rowset schema, no index file 
is written."
+                      << " tablet_id=" << _tablet->tablet_id()
+                      << " rowset_id=" << 
output_rowset_meta->rowset_id().to_string()
+                      << " source_rows=" << output_rowset_meta->num_rows();
+            return Status::OK();
+        }
+        const auto& fs = output_rowset_meta->fs();
         size_t inverted_index_size = 0;
         for (auto& seg_ptr : segments) {
             std::string index_path_prefix {
diff --git a/be/test/io/fs/s3_file_writer_test.cpp 
b/be/test/io/fs/s3_file_writer_test.cpp
index 5678381b26b..0bb6f3c615b 100644
--- a/be/test/io/fs/s3_file_writer_test.cpp
+++ b/be/test/io/fs/s3_file_writer_test.cpp
@@ -1529,6 +1529,10 @@ TEST_F(S3FileWriterTest, write_buffer_boundary) {
 }
 
 TEST_F(S3FileWriterTest, test_empty_file) {
+    bool enable_file_cache = config::enable_file_cache;
+    config::enable_file_cache = false;
+    Defer defer {[&]() { config::enable_file_cache = enable_file_cache; }};
+
     std::vector<StorePath> paths;
     paths.emplace_back(std::string("tmp_dir"), 1024000000);
     auto tmp_file_dirs = std::make_unique<segment_v2::TmpFileDirs>(paths);
@@ -1537,11 +1541,13 @@ TEST_F(S3FileWriterTest, test_empty_file) {
     doris::io::FileWriterOptions opts;
     io::FileWriterPtr file_writer;
     auto st = s3_fs->create_file("test_empty_file.idx", &file_writer, &opts);
-    EXPECT_TRUE(st.ok()) << st;
+    ASSERT_TRUE(st.ok()) << st;
     auto holder = std::make_shared<ObjClientHolder>(S3ClientConf {});
     auto mock_client = std::make_shared<SimpleMockObjStorageClient>();
     holder->_client = mock_client;
     dynamic_cast<io::S3FileWriter*>(file_writer.get())->_obj_client = holder;
+    auto* s3_writer = file_writer.get();
+    const auto file_path = s3_writer->path().native();
     auto fs = io::global_local_filesystem();
     std::string index_path = "/tmp/empty_index_file_test";
     std::string rowset_id = "1234567890";
@@ -1549,8 +1555,20 @@ TEST_F(S3FileWriterTest, test_empty_file) {
     auto index_file_writer = std::make_unique<segment_v2::IndexFileWriter>(
             fs, index_path, rowset_id, seg_id, 
InvertedIndexStorageFormatPB::V2,
             std::move(file_writer), false);
-    EXPECT_TRUE(index_file_writer->begin_close().ok());
-    EXPECT_TRUE(index_file_writer->finish_close().ok());
+    ASSERT_TRUE(index_file_writer->begin_close().ok());
+    EXPECT_EQ(s3_writer->state(), io::FileWriter::State::ASYNC_CLOSING);
+    ASSERT_TRUE(index_file_writer->finish_close().ok());
+    EXPECT_EQ(s3_writer->state(), io::FileWriter::State::CLOSED);
+    EXPECT_EQ(s3_writer->bytes_appended(), 0);
+    // Idempotent: a retried finish must not PUT a second object.
+    ASSERT_TRUE(index_file_writer->finish_close().ok());
+    index_file_writer.reset();
+    // An empty remote index file is one zero-byte object, not a multipart 
upload
+    // and not a missing key.
+    EXPECT_EQ(mock_client->put_object_count, 1);
+    EXPECT_EQ(mock_client->upload_part_count, 0);
+    ASSERT_EQ(mock_client->objects.count(file_path), 1);
+    EXPECT_TRUE(mock_client->objects.at(file_path).empty());
 }
 
 } // namespace doris
diff --git a/be/test/storage/index/index_builder_test.cpp 
b/be/test/storage/index/index_builder_test.cpp
index dd36ba3ab33..bba51abc9c3 100644
--- a/be/test/storage/index/index_builder_test.cpp
+++ b/be/test/storage/index/index_builder_test.cpp
@@ -20,6 +20,9 @@
 #include <gmock/gmock.h>
 #include <gtest/gtest.h>
 
+#include <set>
+
+#include "storage/index/index_file_reader.h"
 #include "storage/olap_common.h"
 #include "storage/rowset/beta_rowset.h"
 #include "storage/rowset/rowset_factory.h"
@@ -169,6 +172,133 @@ protected:
         rs_meta->set_tablet_schema(tablet_schema);
     }
 
+    // Builds a k2 index over a rowset whose schema owns a k1 index that was 
never
+    // written, and returns the index ids present in the output rowset's index
+    // file. `leave_empty_index_file` picks between the two shapes a source
+    // segment with no index content can have on disk: a zero-byte file (read 
as
+    // INVERTED_INDEX_BYPASS) or no file at all 
(INVERTED_INDEX_FILE_NOT_FOUND).
+    //
+    // The rowset is written BEFORE the schema gains k1, so k1 has no content
+    // anywhere and nothing can be lost by either shape. That is not an 
artifact
+    // of the test: a zero-byte index file is only ever produced when no 
logical
+    // index opened a directory, and every non-VARIANT index opens its 
directory
+    // eagerly in IndexColumnWriter::create(). A zero-byte file therefore 
cannot
+    // be a former home of some other index's data.
+    // GTest assertions stay together so the two source-file shapes cannot 
drift.
+    // 
NOLINTNEXTLINE(readability-function-cognitive-complexity,readability-function-size)
+    std::set<int64_t> build_k2_index_over_unwritten_k1(int64_t tablet_id, 
int64_t rowset_id,
+                                                       bool 
leave_empty_index_file) {
+        auto tablet_path = _absolute_dir + "/" + std::to_string(tablet_id);
+        _tablet->_tablet_path = tablet_path;
+        
EXPECT_TRUE(io::global_local_filesystem()->delete_directory(tablet_path).ok());
+        
EXPECT_TRUE(io::global_local_filesystem()->create_directory(tablet_path).ok());
+
+        RowsetSharedPtr rowset;
+        RowsetWriterContext writer_context;
+        writer_context.rowset_id.init(rowset_id);
+        writer_context.tablet_id = rowset_id;
+        writer_context.tablet_schema_hash = 567997577;
+        writer_context.partition_id = 10;
+        writer_context.rowset_type = BETA_ROWSET;
+        writer_context.tablet_path = _absolute_dir + "/" + 
std::to_string(rowset_id);
+        writer_context.rowset_state = VISIBLE;
+        writer_context.tablet_schema = _tablet_schema;
+        writer_context.version.first = 10;
+        writer_context.version.second = 10;
+        EXPECT_TRUE(
+                
io::global_local_filesystem()->create_directory(writer_context.tablet_path).ok());
+
+        auto res = RowsetFactory::create_rowset_writer(*_engine_ref, 
writer_context, false);
+        EXPECT_TRUE(res.has_value()) << res.error();
+        auto rowset_writer = std::move(res).value();
+        {
+            Block block = _tablet_schema->create_block();
+            {
+                auto columns_guard = block.mutate_columns_scoped();
+                auto& columns = columns_guard.mutable_columns();
+                for (int i = 0; i < 1000; ++i) {
+                    int32_t k1 = i * 10;
+                    columns[0]->insert_data((const char*)&k1, sizeof(k1));
+                    int32_t k2 = i % 100;
+                    columns[1]->insert_data((const char*)&k2, sizeof(k2));
+                }
+            }
+            EXPECT_TRUE(rowset_writer->add_block(&block).ok());
+            EXPECT_TRUE(rowset_writer->flush().ok());
+            EXPECT_TRUE(rowset_writer->build(rowset).ok());
+            EXPECT_TRUE(_tablet->add_rowset(rowset).ok());
+        }
+
+        // The schema gains k1 only now, so the rowset owns an index whose 
content
+        // was never written -- the same state an all-NULL VARIANT column 
leaves.
+        TabletIndex k1_index;
+        k1_index._index_id = 1;
+        k1_index._index_name = "k1_index";
+        k1_index._index_type = IndexType::INVERTED;
+        k1_index._col_unique_ids.push_back(1);
+        _tablet_schema->append_index(std::move(k1_index));
+
+        auto segment_path = rowset->segment_path(0);
+        EXPECT_TRUE(segment_path.has_value()) << segment_path.error();
+        const std::string source_prefix {
+                
segment_v2::InvertedIndexDescriptor::get_index_file_path_prefix(
+                        segment_path.value())};
+        const auto source_index_path =
+                
segment_v2::InvertedIndexDescriptor::get_index_file_path_v2(source_prefix);
+        bool exists = true;
+        EXPECT_TRUE(io::global_local_filesystem()->exists(source_index_path, 
&exists).ok());
+        EXPECT_FALSE(exists) << "an index-less rowset must not have written an 
index file";
+        if (leave_empty_index_file) {
+            io::FileWriterPtr empty;
+            
EXPECT_TRUE(io::global_local_filesystem()->create_file(source_index_path, 
&empty).ok());
+            EXPECT_TRUE(empty->close().ok());
+        }
+        {
+            auto reader = std::make_unique<segment_v2::IndexFileReader>(
+                    io::global_local_filesystem(), source_prefix, 
InvertedIndexStorageFormatPB::V2);
+            auto st = reader->init();
+            EXPECT_TRUE(leave_empty_index_file ? 
st.is<ErrorCode::INVERTED_INDEX_BYPASS>()
+                                               : 
st.is<ErrorCode::INVERTED_INDEX_FILE_NOT_FOUND>())
+                    << st;
+        }
+
+        TOlapTableIndex k2_index;
+        k2_index.index_id = 2;
+        k2_index.columns.emplace_back("k2");
+        k2_index.index_name = "k2_index";
+        k2_index.index_type = TIndexType::INVERTED;
+        _alter_indexes.clear();
+        _alter_indexes.push_back(k2_index);
+
+        IndexBuilder 
builder(ExecEnv::GetInstance()->storage_engine().to_local(), _tablet, _columns,
+                             _alter_indexes, false);
+        EXPECT_TRUE(builder.init().ok());
+        auto status = builder.do_build_inverted_index();
+        EXPECT_TRUE(status.ok()) << status.to_string();
+
+        std::set<int64_t> output_index_ids;
+        EXPECT_EQ(builder._output_rowsets.size(), 1);
+        if (builder._output_rowsets.empty()) {
+            return output_index_ids;
+        }
+        auto output_segment_path = builder._output_rowsets[0]->segment_path(0);
+        EXPECT_TRUE(output_segment_path.has_value()) << 
output_segment_path.error();
+        auto reader = std::make_unique<segment_v2::IndexFileReader>(
+                io::global_local_filesystem(),
+                std::string 
{segment_v2::InvertedIndexDescriptor::get_index_file_path_prefix(
+                        output_segment_path.value())},
+                InvertedIndexStorageFormatPB::V2);
+        EXPECT_TRUE(reader->init().ok());
+        auto dirs = reader->get_all_directories();
+        EXPECT_TRUE(dirs.has_value());
+        if (dirs.has_value()) {
+            for (const auto& [key, _] : dirs.value()) {
+                output_index_ids.insert(key.first);
+            }
+        }
+        return output_index_ids;
+    }
+
     StorageEngine* _engine_ref = nullptr;
     TabletSharedPtr _tablet;
     TabletMetaSharedPtr _tablet_meta;
@@ -330,7 +460,8 @@ TEST_F(IndexBuilderTest, DropInvertedIndexTest) {
             new_dat_file_count++;
         }
     }
-    // The index should have been removed
+    // The index should have been removed. Dropping the last index leaves a 
schema
+    // that owns no index file, so the output rowset must carry none.
     EXPECT_EQ(old_idx_file_count, 1) << "Tablet path should have 1 .idx file 
before drop";
     EXPECT_EQ(old_dat_file_count, 1) << "Tablet path should have 1 .dat file 
before drop";
     EXPECT_EQ(new_idx_file_count, 0) << "Tablet path should have no .idx file 
after drop";
@@ -649,6 +780,26 @@ TEST_F(IndexBuilderTest, 
BuildInvertedIndexAfterWritingDataTest) {
     //EXPECT_TRUE(tablet_schema->has_inverted_index_with_index_id(2));
 }
 
+// A schema can own an inverted index whose index file holds nothing: an 
all-NULL
+// VARIANT column extracts no subcolumn, so no logical index directory is ever
+// opened and the file is closed with nothing in it. ALTER on such a rowset 
must
+// read that exactly like a rowset written before any index existed -- there is
+// nothing to carry over, and every requested index is built from the raw 
columns.
+TEST_F(IndexBuilderTest, BuildIndexOverEmptyIndexFileTest) {
+    // Without tolerating the empty index file this fails the whole ALTER with
+    // [E-6004]inverted index file ... is empty.
+    EXPECT_EQ(build_k2_index_over_unwritten_k1(14695, 15695, true), 
(std::set<int64_t> {2}));
+}
+
+// The same rowset with NO index file at all. This path has tolerated
+// INVERTED_INDEX_FILE_NOT_FOUND for years, and it produces exactly the same
+// output: the requested index is built, and an index the source never held is
+// not invented. Pinning both together is the point -- an empty index file is
+// being read the way a missing one already was, not given new semantics.
+TEST_F(IndexBuilderTest, BuildIndexOverMissingIndexFileTest) {
+    EXPECT_EQ(build_k2_index_over_unwritten_k1(14696, 15696, false), 
(std::set<int64_t> {2}));
+}
+
 TEST_F(IndexBuilderTest, BuildAnnIndexAfterWritingDataTest) {
     // 0. prepare tablet path
     auto tablet_path = _absolute_dir + "/" + std::to_string(14686);
diff --git a/be/test/storage/index/inverted/empty_index_file_test.cpp 
b/be/test/storage/index/inverted/empty_index_file_test.cpp
index 94f1a649320..cf645b1ba8a 100644
--- a/be/test/storage/index/inverted/empty_index_file_test.cpp
+++ b/be/test/storage/index/inverted/empty_index_file_test.cpp
@@ -20,8 +20,12 @@
 
 #include "exec/sink/load_stream_stub.h"
 #include "gtest/gtest_pred_impl.h"
+#include "io/fs/local_file_system.h"
 #include "io/fs/stream_sink_file_writer.h"
+#include "storage/index/index_file_reader.h"
 #include "storage/index/index_file_writer.h"
+#include "storage/index/index_writer.h"
+#include "storage/index/inverted/inverted_index_desc.h"
 #include "storage/olap_common.h"
 
 namespace doris {
@@ -29,36 +33,109 @@ namespace doris {
 constexpr int64_t LOAD_ID_LO = 1;
 constexpr int64_t LOAD_ID_HI = 2;
 constexpr int64_t NUM_STREAM = 3;
-constexpr static std::string_view tmp_dir = "./ut_dir/tmp";
-class EmptyIndexFileTest : public testing::Test {
+constexpr static std::string_view tmp_dir = "./ut_dir/empty_index_file";
+class EmptyIndexFileTest : public 
testing::TestWithParam<InvertedIndexStorageFormatPB> {
+    struct WriteState {
+        size_t bytes_appended = 0;
+        int data_calls = 0;
+        int eos_calls = 0;
+    };
+
     class MockStreamStub : public LoadStreamStub {
     public:
-        MockStreamStub(PUniqueId load_id, int64_t src_id)
+        MockStreamStub(PUniqueId load_id, int64_t src_id, 
std::shared_ptr<WriteState> state)
                 : LoadStreamStub(load_id, src_id, 
std::make_shared<IndexToTabletSchema>(),
-                                 std::make_shared<IndexToEnableMoW>()) {};
+                                 std::make_shared<IndexToEnableMoW>()),
+                  _state(std::move(state)) {};
 
-        virtual ~MockStreamStub() = default;
+        ~MockStreamStub() override = default;
 
         // APPEND_DATA
-        virtual Status append_data(int64_t partition_id, int64_t index_id, 
int64_t tablet_id,
-                                   int32_t segment_id, uint64_t offset, 
std::span<const Slice> data,
-                                   bool segment_eos = false,
-                                   FileType file_type = 
FileType::SEGMENT_FILE) override {
-            EXPECT_TRUE(segment_eos);
+        Status append_data(int64_t partition_id, int64_t index_id, int64_t 
tablet_id,
+                           int32_t segment_id, uint64_t offset, 
std::span<const Slice> data,
+                           bool segment_eos = false,
+                           FileType file_type = FileType::SEGMENT_FILE) 
override {
+            EXPECT_EQ(offset, _state->bytes_appended);
+            if (segment_eos) {
+                ++_state->eos_calls;
+                EXPECT_TRUE(data.empty());
+                return Status::OK();
+            }
+            ++_state->data_calls;
+            for (const auto& slice : data) {
+                _state->bytes_appended += slice.size;
+            }
             return Status::OK();
         }
+
+    private:
+        std::shared_ptr<WriteState> _state;
     };
 
 public:
     EmptyIndexFileTest() = default;
-    ~EmptyIndexFileTest() = default;
+    ~EmptyIndexFileTest() override = default;
 
 protected:
-    virtual void SetUp() {
+    // Implements FileWriter and NOTHING else: no concrete writer type can be
+    // recognised here, so a close path that dispatches on the implementation
+    // instead of the interface leaves this writer open and fails the test.
+    class RecordingFileWriter final : public io::FileWriter {
+    public:
+        Status close(bool non_block = false) override {
+            close_calls.push_back(non_block);
+            EXPECT_NE(_state, State::CLOSED);
+            if (non_block) {
+                EXPECT_EQ(_state, State::OPENED);
+                RETURN_IF_ERROR(begin_status);
+                _state = close_synchronously ? State::CLOSED : 
State::ASYNC_CLOSING;
+                return Status::OK();
+            }
+            EXPECT_EQ(_state, State::ASYNC_CLOSING);
+            _state = State::CLOSED;
+            return finish_status;
+        }
+
+        Status appendv(const Slice* data, size_t data_cnt) override {
+            ++append_calls;
+            for (size_t i = 0; i < data_cnt; ++i) {
+                _bytes_appended += data[i].size;
+            }
+            return Status::OK();
+        }
+
+        const io::Path& path() const override { return _path; }
+        size_t bytes_appended() const override { return _bytes_appended; }
+        State state() const override { return _state; }
+
+        std::vector<bool> close_calls;
+        int append_calls = 0;
+        bool close_synchronously = false;
+        Status begin_status = Status::OK();
+        Status finish_status = Status::OK();
+
+    private:
+        io::Path _path {"recording_0.idx"};
+        size_t _bytes_appended = 0;
+        State _state = State::OPENED;
+    };
+
+    static std::string index_path_prefix() { return std::string(tmp_dir) + 
"/empty_0"; }
+
+    static std::unique_ptr<segment_v2::IndexFileWriter> make_index_writer(
+            io::FileWriterPtr file_writer, InvertedIndexStorageFormatPB 
format) {
+        return 
std::make_unique<segment_v2::IndexFileWriter>(io::global_local_filesystem(),
+                                                             
index_path_prefix(), "empty", 0,
+                                                             format, 
std::move(file_writer), false);
+    }
+
+    void SetUp() override {
         _load_id.set_hi(LOAD_ID_HI);
         _load_id.set_lo(LOAD_ID_LO);
         for (int src_id = 0; src_id < NUM_STREAM; src_id++) {
-            _streams.emplace_back(new MockStreamStub(_load_id, src_id));
+            auto state = std::make_shared<WriteState>();
+            _write_states.push_back(state);
+            _streams.emplace_back(new MockStreamStub(_load_id, src_id, 
std::move(state)));
         }
         
EXPECT_TRUE(io::global_local_filesystem()->delete_directory(tmp_dir).ok());
         
EXPECT_TRUE(io::global_local_filesystem()->create_directory(tmp_dir).ok());
@@ -69,25 +146,153 @@ protected:
         ExecEnv::GetInstance()->set_tmp_file_dir(std::move(tmp_file_dirs));
     }
 
-    virtual void TearDown() {
+    void TearDown() override {
         
EXPECT_TRUE(io::global_local_filesystem()->delete_directory(tmp_dir).ok());
     }
 
     PUniqueId _load_id;
     std::vector<std::shared_ptr<LoadStreamStub>> _streams;
+    std::vector<std::shared_ptr<WriteState>> _write_states;
 };
 
-TEST_F(EmptyIndexFileTest, test_empty_index_file) {
-    io::FileWriterPtr file_writer = 
std::make_unique<io::StreamSinkFileWriter>(_streams);
+TEST_P(EmptyIndexFileTest, PreservesZeroByteFileWhenNoLogicalIndexes) {
+    auto file_writer = std::make_unique<io::StreamSinkFileWriter>(_streams);
+    file_writer->init(_load_id, 1, 2, 3, 0, FileType::INVERTED_INDEX_FILE);
+    auto* stream_writer = file_writer.get();
+    auto index_file_writer = make_index_writer(std::move(file_writer), 
GetParam());
+    ASSERT_TRUE(index_file_writer->begin_close().ok());
+    EXPECT_EQ(stream_writer->state(), io::FileWriter::State::ASYNC_CLOSING);
+    ASSERT_TRUE(index_file_writer->finish_close().ok());
+    EXPECT_EQ(stream_writer->state(), io::FileWriter::State::CLOSED);
+    // Finishing an already closed empty file must not send a second EOS.
+    ASSERT_TRUE(index_file_writer->finish_close().ok());
+    index_file_writer.reset();
+    for (const auto& state : _write_states) {
+        EXPECT_EQ(state->bytes_appended, 0);
+        EXPECT_EQ(state->data_calls, 0);
+        EXPECT_EQ(state->eos_calls, 1);
+    }
+}
+
+TEST_P(EmptyIndexFileTest, ClosesOpaqueWriterWithoutAppending) {
+    auto file_writer = std::make_unique<RecordingFileWriter>();
+    auto* recording = file_writer.get();
+    auto index_writer = make_index_writer(std::move(file_writer), GetParam());
+
+    ASSERT_TRUE(index_writer->begin_close().ok());
+    EXPECT_EQ(recording->close_calls, (std::vector<bool> {true}));
+    EXPECT_EQ(recording->state(), io::FileWriter::State::ASYNC_CLOSING);
+    ASSERT_TRUE(index_writer->finish_close().ok());
+    EXPECT_EQ(recording->close_calls, (std::vector<bool> {true, false}));
+    EXPECT_EQ(recording->state(), io::FileWriter::State::CLOSED);
+    ASSERT_TRUE(index_writer->finish_close().ok());
+    EXPECT_EQ(recording->close_calls, (std::vector<bool> {true, false}));
+    // An empty index file is empty: closing it must not write a header.
+    EXPECT_EQ(recording->append_calls, 0);
+    EXPECT_EQ(recording->bytes_appended(), 0);
+}
+
+TEST_P(EmptyIndexFileTest, SkipsAlreadyClosedWriter) {
+    auto file_writer = std::make_unique<RecordingFileWriter>();
+    auto* recording = file_writer.get();
+    ASSERT_TRUE(recording->close(true).ok());
+    ASSERT_TRUE(recording->close(false).ok());
+    recording->close_calls.clear();
+    auto index_writer = make_index_writer(std::move(file_writer), GetParam());
+    ASSERT_TRUE(index_writer->begin_close().ok());
+    ASSERT_TRUE(index_writer->finish_close().ok());
+    EXPECT_TRUE(recording->close_calls.empty());
+}
+
+TEST_P(EmptyIndexFileTest, SkipsFinishWhenBeginClosesSynchronously) {
+    auto file_writer = std::make_unique<RecordingFileWriter>();
+    auto* recording = file_writer.get();
+    recording->close_synchronously = true;
+    auto index_writer = make_index_writer(std::move(file_writer), GetParam());
+    ASSERT_TRUE(index_writer->begin_close().ok());
+    EXPECT_EQ(recording->state(), io::FileWriter::State::CLOSED);
+    ASSERT_TRUE(index_writer->finish_close().ok());
+    EXPECT_EQ(recording->close_calls, (std::vector<bool> {true}));
+}
+
+TEST_P(EmptyIndexFileTest, PropagatesBeginCloseError) {
+    auto file_writer = std::make_unique<RecordingFileWriter>();
+    auto* recording = file_writer.get();
+    recording->begin_status = Status::IOError("begin close failed");
+    auto index_writer = make_index_writer(std::move(file_writer), GetParam());
+    auto st = index_writer->begin_close();
+    EXPECT_EQ(st.to_string(), recording->begin_status.to_string());
+    EXPECT_EQ(recording->close_calls, (std::vector<bool> {true}));
+}
+
+TEST_P(EmptyIndexFileTest, PropagatesFinishCloseError) {
+    auto file_writer = std::make_unique<RecordingFileWriter>();
+    auto* recording = file_writer.get();
+    recording->finish_status = Status::IOError("finish close failed");
+    auto index_writer = make_index_writer(std::move(file_writer), GetParam());
+    ASSERT_TRUE(index_writer->begin_close().ok());
+    auto st = index_writer->finish_close();
+    EXPECT_EQ(st.to_string(), recording->finish_status.to_string());
+    EXPECT_EQ(recording->close_calls, (std::vector<bool> {true, false}));
+}
+
+TEST_P(EmptyIndexFileTest, AllowsNullWriter) {
+    // V1 keeps one file per logical index and never owns a container; V2/V3 
own
+    // one but may be constructed without it (the drop path for a V1 rowset).
+    for (auto format : {InvertedIndexStorageFormatPB::V1, GetParam()}) {
+        auto index_writer = make_index_writer(nullptr, format);
+        ASSERT_TRUE(index_writer->begin_close().ok());
+        ASSERT_TRUE(index_writer->finish_close().ok());
+    }
+}
+
+TEST_P(EmptyIndexFileTest, PreservesLocalEmptyFileAfterDestruction) {
     auto fs = io::global_local_filesystem();
-    std::string index_path = "/tmp/empty_index_file_test";
-    std::string rowset_id = "1234567890";
-    int64_t seg_id = 1234567890;
-    auto index_file_writer = std::make_unique<segment_v2::IndexFileWriter>(
-            fs, index_path, rowset_id, seg_id, 
InvertedIndexStorageFormatPB::V2,
-            std::move(file_writer), false);
-    EXPECT_TRUE(index_file_writer->begin_close().ok());
-    EXPECT_TRUE(index_file_writer->finish_close().ok());
+    const auto path =
+            
segment_v2::InvertedIndexDescriptor::get_index_file_path_v2(index_path_prefix());
+    io::FileWriterPtr file_writer;
+    ASSERT_TRUE(fs->create_file(path, &file_writer).ok());
+    auto* local_writer = file_writer.get();
+    bool exists = false;
+    ASSERT_TRUE(fs->exists(path, &exists).ok());
+    ASSERT_TRUE(exists); // O_CREAT alone does not guarantee persistence.
+    auto index_writer = make_index_writer(std::move(file_writer), GetParam());
+    ASSERT_TRUE(index_writer->begin_close().ok());
+    EXPECT_EQ(local_writer->state(), io::FileWriter::State::ASYNC_CLOSING);
+    ASSERT_TRUE(index_writer->finish_close().ok());
+    EXPECT_EQ(local_writer->state(), io::FileWriter::State::CLOSED);
+    EXPECT_EQ(local_writer->bytes_appended(), 0);
+    // ~LocalFileWriter aborts (and DELETES) a writer it was never asked to 
close.
+    index_writer.reset();
+
+    ASSERT_TRUE(fs->exists(path, &exists).ok());
+    ASSERT_TRUE(exists);
+    int64_t file_size = -1;
+    ASSERT_TRUE(fs->file_size(path, &file_size).ok());
+    EXPECT_EQ(file_size, 0);
+
+    // The contract every reader of this file relies on: a zero-length 
container
+    // is not corruption, it is "this segment has no index data". Query falls 
back
+    // to a non-indexed evaluation on it and IndexBuilder treats it as 
"nothing to
+    // carry over"; both branch on this exact error code.
+    auto reader =
+            std::make_unique<segment_v2::IndexFileReader>(fs, 
index_path_prefix(), GetParam());
+    auto st = reader->init();
+    EXPECT_TRUE(st.is<ErrorCode::INVERTED_INDEX_BYPASS>()) << st;
+    EXPECT_NE(st.to_string().find(" is empty"), std::string::npos) << st;
+}
+
+TEST_P(EmptyIndexFileTest, MissingLocalFileReadsAsFileNotFound) {
+    // The other half of the same contract: a rowset written before any index
+    // existed has no container at all, and that is distinct from an empty one.
+    auto reader = std::make_unique<segment_v2::IndexFileReader>(
+            io::global_local_filesystem(), std::string(tmp_dir) + "/absent_0", 
GetParam());
+    auto st = reader->init();
+    EXPECT_TRUE(st.is<ErrorCode::INVERTED_INDEX_FILE_NOT_FOUND>()) << st;
 }
 
+INSTANTIATE_TEST_SUITE_P(LegacyCompoundFormats, EmptyIndexFileTest,
+                         testing::Values(InvertedIndexStorageFormatPB::V2,
+                                         InvertedIndexStorageFormatPB::V3));
+
 } // namespace doris
diff --git a/be/test/storage/variant/index_storage_variant_debug_point_test.cpp 
b/be/test/storage/variant/index_storage_variant_debug_point_test.cpp
index 720a83b342a..180614adedb 100644
--- a/be/test/storage/variant/index_storage_variant_debug_point_test.cpp
+++ b/be/test/storage/variant/index_storage_variant_debug_point_test.cpp
@@ -136,6 +136,7 @@ protected:
         auto probe = probe_rowset(rowset.value());
         EXPECT_TRUE(probe.has_value()) << probe.error();
         if (probe.has_value()) {
+            // The schema owns the index, so a null array still leaves an 
index file.
             expect_index_files(probe.value(), true);
         }
         return rowset.value();
diff --git 
a/regression-test/suites/inverted_index_p0/test_empty_index_file_lifecycle.groovy
 
b/regression-test/suites/inverted_index_p0/test_empty_index_file_lifecycle.groovy
new file mode 100644
index 00000000000..04b53207ae9
--- /dev/null
+++ 
b/regression-test/suites/inverted_index_p0/test_empty_index_file_lifecycle.groovy
@@ -0,0 +1,178 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Lifecycle of a segment whose index file is legitimately empty.
+//
+// An all-NULL VARIANT column extracts no subcolumn, so the only indexed column
+// opens no logical index and the segment's index file is closed with nothing 
in
+// it. The invariant, in both directions:
+//
+//     rowset schema owns an inverted index  <=>  every segment owns an index 
file
+//
+// An empty index file (E-6004 "is empty") is therefore expected wherever a
+// rowset's schema still has the index, and NO index file at all (E-6003 "not
+// found") is expected once it has none. Every producer has to keep the first 
half
+// true -- load, compaction, light and direct schema change, BUILD INDEX -- and
+// every consumer has to accept an empty index file instead of failing on it.
+suite("test_empty_index_file_lifecycle", "p0") {
+    def backendId_to_backendIP = [:]
+    def backendId_to_backendHttpPort = [:]
+    getBackendIpHttpPort(backendId_to_backendIP, backendId_to_backendHttpPort)
+
+    // show_nested_index_file reports the first rowset it cannot open, so 
there is
+    // one status per tablet: "E-6004" empty index file, "E-6003" none at all,
+    // "OK" an index file with real indexes in it.
+    def index_file_status = { String tableName ->
+        def statuses = [] as Set
+        for (def tablet : sql_return_maparray(" show tablets from ${tableName} 
")) {
+            String ip = backendId_to_backendIP.get(tablet.BackendId)
+            String port = backendId_to_backendHttpPort.get(tablet.BackendId)
+            def (code, out, err) = http_client("GET", String.format(
+                    "http://%s:%s/api/show_nested_index_file?tablet_id=%s";, 
ip, port, tablet.TabletId))
+            logger.info("show_nested_index_file tablet=${tablet.TabletId}: 
code=${code}, out=${out}, err=${err}")
+            statuses.add(code == 500 ? parseJson(out.trim()).status : "OK")
+        }
+        return statuses
+    }
+
+    // Both SHOW lists are empty for a table that never had such a job, so this
+    // waits for "nothing pending" rather than for a FINISHED row to appear.
+    def wait_alter_done = { String tableName ->
+        for (int i = 0; i < 600; i++) {
+            def jobs = sql_return_maparray(""" SHOW ALTER TABLE COLUMN WHERE 
TableName = "${tableName}" """) +
+                       sql_return_maparray(""" SHOW BUILD INDEX WHERE 
TableName = "${tableName}" """)
+            def cancelled = jobs.findAll { it.State == "CANCELLED" }
+            assertTrue(cancelled.isEmpty(), "job cancelled on ${tableName}: 
${cancelled}")
+            if (jobs.every { it.State == "FINISHED" }) {
+                return
+            }
+            sleep(1000)
+        }
+        assertTrue(false, "schema change or index job on ${tableName} did not 
finish")
+    }
+
+    // The rewrite lands asynchronously on the BE, so poll the invariant 
instead
+    // of racing the FE job record.
+    def assert_index_file_status = { String tableName, String expected, String 
step ->
+        def observed = null
+        for (int i = 0; i < 180; i++) {
+            observed = index_file_status(tableName)
+            if (observed == ([expected] as Set)) {
+                return
+            }
+            sleep(1000)
+        }
+        assertEquals([expected] as Set, observed, "after ${step}")
+    }
+
+    def assert_readable = { String tableName, int rows = 2 ->
+        assertEquals(rows, sql(" select count(*) from ${tableName} ")[0][0] as 
int)
+        assertEquals(1, sql(" select count(*) from ${tableName} where id = 1 
")[0][0] as int)
+    }
+
+    // v2 covers both write paths -- memtable on the sink node streams the 
index
+    // file through StreamSinkFileWriter, the local path writes it through
+    // LocalFileWriter -- and v3 covers the other storage format.
+    [["v2", true], ["v2", false], ["v3", false]].each { fmt, sinkNode ->
+        def tableName = "test_empty_idx_lifecycle_${fmt}_sink${sinkNode ? 1 : 
0}"
+        sql " drop table if exists ${tableName} "
+        sql """
+            CREATE TABLE ${tableName} (
+                `id`  bigint NOT NULL,
+                `v`   variant NULL,
+                INDEX v_idx (`v`) USING INVERTED
+            ) DUPLICATE KEY(`id`)
+            DISTRIBUTED BY HASH(`id`) BUCKETS 1
+            PROPERTIES (
+                "replication_allocation" = "tag.location.default: 1",
+                "inverted_index_storage_format" = "${fmt}",
+                "disable_auto_compaction" = "true"
+            );
+        """
+        sql " set enable_memtable_on_sink_node = ${sinkNode} "
+
+        // 1. load: the only indexed column is all NULL, so the index file is 
empty
+        sql " insert into ${tableName} values (1, NULL) "
+        sql " insert into ${tableName} values (2, NULL) "
+        sql "sync"
+        assert_index_file_status(tableName, "E-6004", "load")
+        assert_readable(tableName)
+
+        // The steps below do not depend on the write path that loaded the 
rows, so
+        // they run once.
+        if (sinkNode) {
+            return
+        }
+
+        // 2. compaction: two empty-index inputs produce one empty-index output
+        trigger_and_wait_compaction(tableName, "full")
+        assert_index_file_status(tableName, "E-6004", "full compaction")
+        assert_readable(tableName)
+
+        // 3. light schema change: a default-valued ADD COLUMN keeps the 
segments
+        //    and their index files (locally it hard-links them)
+        sql """ ALTER TABLE ${tableName} ADD COLUMN c INT DEFAULT "7" """
+        wait_alter_done(tableName)
+        assert_index_file_status(tableName, "E-6004", "light schema change")
+        assert_readable(tableName)
+
+        // 4. direct schema change: a type change rewrites every rowset, so the
+        //    output segments write their own empty index files. The default 
has
+        //    to be repeated, or FE rejects the statement as a default value 
change.
+        sql """ ALTER TABLE ${tableName} MODIFY COLUMN c BIGINT DEFAULT "7" """
+        wait_alter_done(tableName)
+        assert_index_file_status(tableName, "E-6004", "direct schema change")
+        assert_readable(tableName)
+
+        if (!isCloudMode()) {
+            // 5. ADD INDEX + BUILD INDEX runs IndexBuilder over the existing 
index
+            //    files. An empty one means "nothing to carry over", exactly 
like a
+            //    rowset written before any index existed; failing on it 
aborts the
+            //    whole ALTER with "[E-6004] ... is empty".
+            sql " ALTER TABLE ${tableName} ADD INDEX id_idx (`id`) USING 
INVERTED "
+            wait_alter_done(tableName)
+            build_index_on_table("id_idx", tableName)
+            wait_alter_done(tableName)
+            assert_index_file_status(tableName, "OK", "BUILD INDEX over an 
empty index file")
+            assert_readable(tableName)
+
+            // 6. dropping one of two indexes leaves an index in the schema, 
so the
+            //    rewritten segments keep an index file -- empty again, 
because the
+            //    surviving index is the all-NULL VARIANT one
+            sql " ALTER TABLE ${tableName} DROP INDEX id_idx "
+            wait_alter_done(tableName)
+            assert_index_file_status(tableName, "E-6004", "dropping one of two 
indexes")
+            assert_readable(tableName)
+        }
+
+        // 7. dropping the LAST index: IndexBuilder does not remove a VARIANT 
index
+        //    from the rowsets it rewrites, so they keep their empty index 
file until
+        //    full compaction rewrites them with a schema that owns no index.
+        sql " ALTER TABLE ${tableName} DROP INDEX v_idx "
+        wait_alter_done(tableName)
+        if (!isCloudMode()) {
+            assert_index_file_status(tableName, "E-6004", "dropping the last 
index")
+        }
+        // Full compaction needs a second rowset, or it has nothing to merge.
+        sql " insert into ${tableName} (id, v) values (3, NULL) "
+        if (!isCloudMode()) {
+            trigger_and_wait_compaction(tableName, "full")
+            assert_index_file_status(tableName, "E-6003", "compacting a table 
with no index")
+        }
+        assert_readable(tableName, 3)
+    }
+}
diff --git 
a/regression-test/suites/inverted_index_p0/test_variant_empty_index_file.groovy 
b/regression-test/suites/inverted_index_p0/test_variant_empty_index_file.groovy
index 93e4cf7b521..724eee97f73 100644
--- 
a/regression-test/suites/inverted_index_p0/test_variant_empty_index_file.groovy
+++ 
b/regression-test/suites/inverted_index_p0/test_variant_empty_index_file.groovy
@@ -16,47 +16,66 @@
 // under the License.
 
 suite("test_variant_empty_index_file", "p0") {
-    def tableName = "test_variant_empty_index_file"
-    sql """ drop table if exists ${tableName} """
-    // create table
-    sql """
-        CREATE TABLE IF NOT EXISTS ${tableName}
-        (   
-            `id`   bigint NOT NULL,
-            `v`    variant NULL,
-            INDEX  v_idx (`v`) USING INVERTED
-        ) DUPLICATE KEY(`id`)
-        DISTRIBUTED BY HASH (`id`) BUCKETS 1
-        PROPERTIES (
-        "replication_allocation" = "tag.location.default: 1",
-        "inverted_index_storage_format" = "v2",
-        "disable_auto_compaction" = "true"
-        );
-    """
-
-    sql """ set enable_memtable_on_sink_node = true """
-    sql """ insert into ${tableName} values (1, NULL) """
-    qt_sql9 "select * from ${tableName}"
-    sql "sync"
-    def tablets = sql_return_maparray """ show tablets from ${tableName}; """
-
     def backendId_to_backendIP = [:]
     def backendId_to_backendHttpPort = [:]
     getBackendIpHttpPort(backendId_to_backendIP, backendId_to_backendHttpPort);
 
-    String tablet_id = tablets[0].TabletId
-    String backend_id = tablets[0].BackendId
-    String ip = backendId_to_backendIP.get(backend_id)
-    String port = backendId_to_backendHttpPort.get(backend_id)
-    def (code, out, err) = http_client("GET", 
String.format("http://%s:%s/api/show_nested_index_file?tablet_id=%s";, ip, port, 
tablet_id))
-    logger.info("Run show_nested_index_file_on_tablet: code=" + code + ", 
out=" + out + ", err=" + err)
-    assertEquals("E-6004", parseJson(out.trim()).status)
-    assertTrue(out.contains(" is empty"))
+    def create_table = { String table ->
+        sql """ drop table if exists ${table} """
+        sql """
+            CREATE TABLE IF NOT EXISTS ${table}
+            (
+                `id`   bigint NOT NULL,
+                `v`    variant NULL,
+                INDEX  v_idx (`v`) USING INVERTED
+            ) DUPLICATE KEY(`id`)
+            DISTRIBUTED BY HASH (`id`) BUCKETS 1
+            PROPERTIES (
+            "replication_allocation" = "tag.location.default: 1",
+            "inverted_index_storage_format" = "v2",
+            "disable_auto_compaction" = "true"
+            );
+        """
+    }
 
-    try {
-        sql """ select /*+ SET_VAR(enable_match_without_inverted_index = 0) */ 
 * from ${tableName} where v match 'abcd';  """
-    } catch (Exception e) {
-        log.info(e.getMessage());
-        assertTrue(e.getMessage().contains("VARIANT root column does not 
support MATCH predicates"))
+    // The schema owns an index, so every segment owns an index file -- but an
+    // all-NULL VARIANT column extracts no subcolumn, so no logical index is 
ever
+    // opened and the file is closed empty. Reading it must say "is empty"
+    // (E-6004), never "not found" (E-6003): the file is there, it just has no
+    // index in it.
+    def assert_empty_index_file = { String table ->
+        sql "sync"
+        def tablets = sql_return_maparray """ show tablets from ${table}; """
+        String tablet_id = tablets[0].TabletId
+        String backend_id = tablets[0].BackendId
+        String ip = backendId_to_backendIP.get(backend_id)
+        String port = backendId_to_backendHttpPort.get(backend_id)
+        def (code, out, err) = http_client("GET", 
String.format("http://%s:%s/api/show_nested_index_file?tablet_id=%s";, ip, port, 
tablet_id))
+        logger.info("Run show_nested_index_file_on_tablet: code=" + code + ", 
out=" + out + ", err=" + err)
+        assertEquals("E-6004", parseJson(out.trim()).status)
+        assertTrue(out.contains(" is empty"))
     }
-}
\ No newline at end of file
+
+    // Memtable on the sink node streams the index file through 
StreamSinkFileWriter.
+    def tableName = "test_variant_empty_index_file"
+    create_table(tableName)
+    sql """ set enable_memtable_on_sink_node = true """
+    sql """ insert into ${tableName} values (1, NULL) """
+    qt_sql9 "select * from ${tableName}"
+    assert_empty_index_file(tableName)
+
+    test {
+        sql """ select /*+ SET_VAR(enable_match_without_inverted_index = 0) */
+                    * from ${tableName} where v match 'abcd' """
+        exception "VARIANT root column does not support MATCH predicates"
+    }
+
+    // The local write path goes through LocalFileWriter, whose destructor 
ABORTS
+    // (and deletes) a writer that was never closed. Creating the file is not
+    // enough; it has to be closed like every other implementation.
+    def localTableName = "test_variant_empty_index_file_local"
+    create_table(localTableName)
+    sql """ set enable_memtable_on_sink_node = false """
+    sql """ insert into ${localTableName} values (1, NULL) """
+    assert_empty_index_file(localTableName)
+}


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

Reply via email to