fix: handle empty doc hits in ElementFilterBitsNode offset mode (#49296)

issue: https://github.com/milvus-io/milvus/issues/49260

Signed-off-by: SpadeA <tangchenjie1210@gmail.com>
This commit is contained in:
Spade A
2026-04-24 14:05:45 +08:00
committed by GitHub
parent fece10a762
commit 345a7725fe
2 changed files with 142 additions and 0 deletions
@@ -182,6 +182,15 @@ PhyElementFilterBitsNode::EvaluateElementExpression(
tracer::AddEvent(fmt::format("offset_mode, input_elements: {}",
element_offsets.size()));
// No doc passed the upstream predicate on this segment: skip Eval
// (expressions return nullptr on empty input) and mark all elements
// as filtered out.
if (element_offsets.empty()) {
TargetBitmap bitset(total_elements, true);
TargetBitmap valid_bitset(total_elements, true);
return std::make_pair(std::move(bitset), std::move(valid_bitset));
}
EvalCtx eval_ctx(operator_context_->get_exec_context(),
&element_offsets);
@@ -1849,6 +1849,139 @@ TEST(ElementFilter, RetrieveSortedByPk) {
EXEC_EVAL_EXPR_BATCH_SIZE.store(saved_batch_size);
}
// Regression test for https://github.com/milvus-io/milvus/issues/49260
// ElementFilterBitsNode: when element_filter is AND-composed with a predicate
// that matches 0 docs on a segment, doc_hit_ratio = 0 triggers offset_mode
// with empty element_offsets. The downstream expression's Eval returns
// nullptr on empty input, violating the "exactly one result" assertion in
// PhyElementFilterBitsNode::EvaluateElementExpression. The fix short-circuits
// the empty-offsets case and returns an all-filtered bitset.
//
// Parameterize over sealed/growing to cover both segment types — the bug is
// triggered purely by a 0-hit upstream predicate, not by segment type.
class ElementFilterEmptyDocHit : public ::testing::TestWithParam<bool> {};
TEST_P(ElementFilterEmptyDocHit, ZeroHitPredicateWithAnd) {
bool with_sealed = GetParam();
int dim = 4;
auto schema = std::make_shared<Schema>();
auto vec_fid = schema->AddDebugVectorArrayField("structA[array_float_vec]",
DataType::VECTOR_FLOAT,
dim,
knowhere::metric::L2);
auto int_array_fid = schema->AddDebugArrayField(
"structA[price_array]", DataType::INT32, false);
auto int64_fid = schema->AddDebugField("id", DataType::INT64);
schema->set_primary_field_id(int64_fid);
size_t N = 1000;
int array_len = 3;
auto raw_data = DataGen(schema, N, 42, 0, 1, array_len);
// doc i -> price_array [i*3+1, i*3+2, i*3+3]
for (int i = 0; i < raw_data.raw_->fields_data_size(); i++) {
auto* field_data = raw_data.raw_->mutable_fields_data(i);
if (field_data->field_id() == int_array_fid.get()) {
field_data->mutable_scalars()
->mutable_array_data()
->mutable_data()
->Clear();
for (size_t row = 0; row < N; row++) {
auto* array_data = field_data->mutable_scalars()
->mutable_array_data()
->mutable_data()
->Add();
for (int elem = 0; elem < array_len; elem++) {
int value = row * array_len + elem + 1;
array_data->mutable_int_data()->mutable_data()->Add(value);
}
}
break;
}
}
// Build segment. The default DataGen assigns PK id in [0, N), so any
// predicate "id >= N" matches zero rows -> doc_hit_ratio = 0 -> offset_mode
// -> empty element_offsets on the segment (the exact trigger for #49260).
std::shared_ptr<SegmentInterface> segment;
if (with_sealed) {
segment = CreateSealedWithFieldDataLoaded(schema, raw_data);
} else {
auto growing = CreateGrowingSegment(schema, empty_index_meta);
growing->PreInsert(N);
growing->Insert(0,
N,
raw_data.row_ids_.data(),
raw_data.timestamps_.data(),
raw_data.raw_);
segment = std::move(growing);
}
// Build retrieve plan: element_filter(structA, $[price_array] >= 0)
// AND id >= N
proto::plan::PlanNode plan_node;
auto* query = plan_node.mutable_query();
query->set_is_count(false);
query->set_limit(100);
auto* expr = query->mutable_predicates();
auto* element_filter = expr->mutable_element_filter_expr();
element_filter->set_struct_name("structA");
// Element-level predicate: value >= 0 (always true for our data).
auto* element_expr = element_filter->mutable_element_expr();
auto* elem_range = element_expr->mutable_unary_range_expr();
auto* elem_col = elem_range->mutable_column_info();
elem_col->set_field_id(int_array_fid.get());
elem_col->set_data_type(proto::schema::DataType::Int32);
elem_col->set_element_type(proto::schema::DataType::Int32);
elem_col->set_is_element_level(true);
elem_range->set_op(proto::plan::OpType::GreaterEqual);
elem_range->mutable_value()->set_int64_val(0);
// Doc-level predicate that matches zero rows: id >= N.
auto* predicate = element_filter->mutable_predicate();
auto* pred_range = predicate->mutable_unary_range_expr();
auto* pred_col = pred_range->mutable_column_info();
pred_col->set_field_id(int64_fid.get());
pred_col->set_data_type(proto::schema::DataType::Int64);
pred_range->set_op(proto::plan::OpType::GreaterEqual);
pred_range->mutable_value()->set_int64_val(static_cast<int64_t>(N));
plan_node.add_output_field_ids(int64_fid.get());
plan_node.add_output_field_ids(int_array_fid.get());
auto parser = ProtoParser(schema);
auto plan = parser.CreateRetrievePlan(plan_node);
// Without the fix this call aborts with:
// Assert "results.size() == 1 && results[0] != nullptr"
// at ElementFilterBitsNode.cpp:191.
std::unique_ptr<proto::segcore::RetrieveResults> retrieve_results;
ASSERT_NO_THROW({
retrieve_results = segment->Retrieve(nullptr,
plan.get(),
1L << 63,
INT64_MAX,
false,
folly::CancellationToken(),
0,
0);
});
ASSERT_NE(retrieve_results, nullptr);
ASSERT_EQ(retrieve_results->offset_size(), 0)
<< "No doc satisfies id >= N, expected empty result";
}
INSTANTIATE_TEST_SUITE_P(ElementFilter,
ElementFilterEmptyDocHit,
::testing::Values(true, false),
[](const ::testing::TestParamInfo<bool>& info) {
return info.param ? "Sealed" : "Growing";
});
enum class NestedIndexType { NONE, STL_SORT, INVERTED };
std::string