github-actions[bot] commented on code in PR #67538:
URL: https://github.com/apache/doris/pull/67538#discussion_r3984903500
##########
be/src/storage/index/snii/snii_index_reader.cpp:
##########
@@ -402,6 +408,33 @@ Status execute_snii_query(const
::doris::snii::reader::LogicalIndexReader& logic
max_expansions);
emitted_to_sink = true;
break;
+ case InvertedIndexQueryType::LIKE_GRAM_QUERY:
+ case InvertedIndexQueryType::REGEXP_GRAM_QUERY: {
+ // Compile against the same physical dictionary that supplies the
postings. Current
+ // policies can differ from the scheme that was used when this segment
was written.
+ const auto& scheme = logical_reader.gram_scheme();
Review Comment:
[P1] Handle MATCH against each segment's persisted scheme. Adaptive
calibration mutates and persists a per-segment density, but MATCH terms are
still parsed with the unchanged current policy analyzer; different CDC
boundaries can therefore make an exact index bitmap omit rows that scalar MATCH
(current analyzer on both value and query) returns. Recovery creates the same
mismatch deterministically with old dense-3/new dense-4 rowsets, while only
LIKE/REGEXP use `logical_reader.gram_scheme()`. The cache lookup also precedes
the reader and omits analyzer/scheme identity. Please skip/recheck MATCH when
schemes differ (including cache handling), and add indexed/unindexed parity for
calibrated and recovered mixed segments.
##########
be/src/storage/index/snii/snii_index_writer.cpp:
##########
@@ -44,13 +46,97 @@
SniiIndexColumnWriter::SniiIndexColumnWriter(IndexFileWriter* index_file_writer,
_index_meta(index_meta),
_is_char(value_type == FieldType::OLAP_FIELD_TYPE_CHAR) {}
+// Gram-family detection (Rulings R21/R22): the scheme comes only from the
single analyzer
+// provider the writer created itself -- the same provider both produces the
actual tokenizer and
+// answers "am I gram family?", so the two cannot drift. A built-in analyzer
+// (standard/english/...) goes through BuiltinAnalyzerProvider and the base
class default, which
+// is always nullopt, and the policy manager is never consulted (consulting it
would throw
+// "Policy not found" and make every built-in-analyzer index impossible to
build).
+void SniiIndexColumnWriter::_apply_gram_family_scheme(
+ const inverted_index::AnalyzerProviderPtr& analyzer_provider) {
+ if (analyzer_provider != nullptr) {
+ _gram_scheme = analyzer_provider->gram_scheme();
+ }
+ // An index-level char_filter is wrapped around the reader by the writer
itself
+ // (create_reader) and is invisible to the provider: once one exists, the
stored term is no
+ // longer equal to GramExtractor.extract(raw column value), breaking the
row invariant the
+ // query side (phase C) relies on, so this is treated as "not gram family"
(fail-safe, for
+ // the same reason as R22).
+ if (!_analyzer_config.char_filter_map.empty()) {
+ _gram_scheme.reset();
+ }
+ DCHECK(!_gram_scheme.has_value() || _should_analyzer);
+ if (!_gram_scheme.has_value() || !_has_positions) {
+ return;
+ }
+ // R15: a gram-family hit forces a degradation to docs-only (the gram
index does not support
+ // phrase positions), and it has to happen before SpimiTermBuffer is fixed
by _has_positions.
+ LOG(INFO) << "gram-family analyzer forces docs-only index, ignoring
support_phrase for index "
+ << _index_meta->index_id();
+ _has_positions = false;
+ _config = ::doris::snii::format::IndexConfig::kDocsOnly;
+}
+
+// Arms the density solve for this segment. The configured density stays as
the fallback: it is
+// what a segment gets when the feature is off, when the sample carries no
window of the
+// promised length, or when nothing at all is written.
+void SniiIndexColumnWriter::_arm_density_calibration() {
+ if (!_gram_scheme.has_value() ||
!config::enable_gram_index_adaptive_density) {
Review Comment:
[P2] Avoid arming density calibration for `DENSE` schemes. Density is
consulted only by the sparse extractor, but this currently makes every dense
writer copy and retain the default 4 MiB sample, update the histogram, and
replay all held rows before emitting terms. With many concurrent tablet/index
writers that is avoidable per-writer memory and CPU for a mode whose output
cannot change. Please restrict calibration to sparse (or a genuinely unresolved
auto mode) and cover dense writers with a no-sampling test.
##########
be/test/storage/index/snii/writer/high_df_digest_cost_test.cpp:
##########
@@ -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.
+
+// What does building the high-df digest cost the writer?
+//
+// The digest earns its keep on the query side -- it lets the cost gate bound
a node without
+// the remote dictionary reads that were measured at 40 seconds on a cold
segment. That is
+// only a good trade if writing it is close to free, and "close to free" has
to be a number
+// rather than an assertion.
+//
+// The two builds below index the identical corpus with the identical settings
and differ in
+// exactly one field: whether a gram scheme is present, which is what decides
that a digest
+// is built at all. Everything else -- postings, dictionary blocks, term
hashes, statistics
+// -- is byte-for-byte the same work, so the difference in elapsed time is the
digest.
+//
+// The shape of the corpus matters to what is measured. Term frequencies
follow a Zipf-like
+// curve, as a real vocabulary does, so most terms fall below the digest's
floor and cost
+// only the one comparison that rejects them, while a small head clears it and
pays for a
+// bounded-heap insertion. Measuring on a flat corpus would either exercise no
heap at all
+// or exercise nothing but the heap; neither resembles a real index.
+
+#include <gtest/gtest.h>
+
+#include <algorithm>
+#include <chrono>
+#include <cmath>
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "storage/index/snii/format/format_constants.h"
+#include "storage/index/snii/reader/logical_index_reader.h"
+#include "storage/index/snii/reader/snii_segment_reader.h"
+#include "storage/index/snii/writer/logical_index_writer.h"
+#include "storage/index/snii/writer/snii_compound_writer.h"
+#include "storage/index/snii_query_test_util.h"
+
+namespace doris::snii::writer {
+namespace {
+
+using snii_test::assert_ok;
+using snii_test::make_term;
+using snii_test::MemoryFile;
+using snii_test::PostingDoc;
+
+constexpr uint32_t kDocCount = 200000;
+constexpr uint32_t kTermCount = 40000;
+constexpr int kRounds = 5;
+
+// A Zipf-like vocabulary over kDocCount documents: term i appears in roughly
+// kTermCount / (i + 1) documents, so a few hundred terms clear the digest
floor
+// (kDocCount / kHighDfDigestDivisor = 100) and the rest sit far below it.
+std::vector<TermPostings> BuildCorpus() {
+ std::vector<TermPostings> terms;
+ terms.reserve(kTermCount);
+ for (uint32_t i = 0; i < kTermCount; i++) {
+ const uint32_t df = std::max<uint32_t>(1, kTermCount / (i + 1));
+ const uint32_t stride = std::max<uint32_t>(1, kDocCount / df);
+ std::vector<PostingDoc> docs;
+ docs.reserve(df);
+ for (uint32_t docid = 0; docid < kDocCount && docs.size() < df; docid
+= stride) {
+ docs.push_back(PostingDoc {.docid = docid, .positions = {}});
+ }
+ // Zero-padded so the terms are already in lexicographic order.
+ char name[32];
+ snprintf(name, sizeof(name), "term_%06u", i);
+ terms.push_back(make_term(name, std::move(docs)));
+ }
+ return terms;
+}
+
+// One build of the whole index. with_digest selects it by supplying a gram
scheme, which is
+// the only thing that differs between the two arms.
+double BuildOnceMs(const std::vector<TermPostings>& corpus, bool with_digest,
+ uint64_t* digest_entries) {
+ SniiIndexInput input;
+ input.index_id = 31;
+ input.index_suffix = "body";
+ input.config = format::IndexConfig::kDocsOnly;
+ input.doc_count = kDocCount;
+ input.terms = corpus;
+ if (with_digest) {
+ segment_v2::gram::GramScheme scheme;
+ scheme.mode = segment_v2::gram::GramMode::SPARSE;
+ scheme.min_len = 3;
+ scheme.max_len = 4;
+ scheme.density_permille = 250;
+ input.gram_scheme = scheme;
+ }
+
+ MemoryFile file;
+ SniiCompoundWriter writer(&file);
+ const auto start = std::chrono::steady_clock::now();
+ assert_ok(writer.add_logical_index(input));
+ assert_ok(writer.finish());
+ const auto end = std::chrono::steady_clock::now();
+
+ if (digest_entries != nullptr) {
+ reader::SniiSegmentReader segment;
+ assert_ok(reader::SniiSegmentReader::open(&file, &segment));
+ reader::LogicalIndexReader index;
+ assert_ok(segment.open_index(31, "body", &index));
+ *digest_entries = index.high_df_terms().term_hash.size();
+ }
+ return std::chrono::duration<double, std::milli>(end - start).count();
+}
+
+double Median(std::vector<double> values) {
+ std::ranges::sort(values);
+ return values[values.size() / 2];
+}
+
+} // namespace
+
+// Reports the cost; asserts only that it is not a large fraction of the
build, so the case
+// records a number without becoming a tripwire for ordinary timing noise.
+TEST(SniiHighDfDigestCost, BuildingTheDigestIsANegligibleShareOfTheWrite) {
+ const std::vector<TermPostings> corpus = BuildCorpus();
+
+ uint64_t entries = 0;
+ std::vector<double> with_digest;
+ std::vector<double> without;
+ for (int round = 0; round < kRounds; round++) {
+ // Alternate the order so a warming effect cannot favour one arm
systematically.
+ if (round % 2 == 0) {
+ with_digest.push_back(BuildOnceMs(corpus, true, &entries));
+ without.push_back(BuildOnceMs(corpus, false, nullptr));
+ } else {
+ without.push_back(BuildOnceMs(corpus, false, nullptr));
+ with_digest.push_back(BuildOnceMs(corpus, true, &entries));
+ }
+ }
+
+ const double on = Median(with_digest);
+ const double off = Median(without);
+ const double overhead_pct = 100.0 * (on - off) / off;
+ // Spread of the two arms, so a reader can see whether the difference
above is signal.
+ const double on_spread =
+ *std::ranges::max_element(with_digest) -
*std::ranges::min_element(with_digest);
+ const double off_spread =
+ *std::ranges::max_element(without) -
*std::ranges::min_element(without);
+
+ printf("\n%u docs, %u terms, digest holds %llu entries\n", kDocCount,
kTermCount,
+ static_cast<unsigned long long>(entries));
+ printf("build with digest : %8.1f ms (median of %d, spread %.1f ms)\n",
on, kRounds,
+ on_spread);
+ printf("build without digest : %8.1f ms (median of %d, spread %.1f ms)\n",
off, kRounds,
+ off_spread);
+ printf("difference : %+8.1f ms (%+.2f%%)\n", on - off,
overhead_pct);
+ printf("per term : %+8.3f us\n", 1000.0 * (on - off) /
kTermCount);
+ // The work itself, which is deterministic and does not depend on the
machine: one
+ // comparison for every term, and a bounded-heap insertion only for those
above the
+ // floor. Reported alongside the timing because it is the number that
stays true.
+ printf("work : %u comparisons + at most %llu heap ops
(log2 K <= %.1f)\n",
+ kTermCount, static_cast<unsigned long long>(entries),
+ entries > 0 ? std::log2(static_cast<double>(entries)) : 0.0);
+
+ EXPECT_GT(entries, 0U) << "the corpus must actually populate the digest";
+ EXPECT_LT(overhead_pct, 5.0)
Review Comment:
[P2] Do not make BE UT success depend on this five-sample wall-clock ratio.
Independent medians and alternating order do not control scheduler preemption,
CPU throttling, sanitizer/allocator effects, or shared-runner contention; three
delayed `with_digest` samples can fail unchanged code above 5%, and delayed
baselines can hide a regression. Move the percentage gate to a controlled
benchmark/perf job or replace it with deterministic work/allocation invariants;
keep timing diagnostic-only in ordinary UT.
##########
be/src/storage/index/inverted/gram/gram_density.cpp:
##########
@@ -0,0 +1,125 @@
+// 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.
+
+#include "storage/index/inverted/gram/gram_density.h"
+
+#include <algorithm>
+
+#include "storage/index/inverted/gram/gram_extractor.h"
+
+namespace doris::segment_v2::gram {
+
+DensitySolver::DensitySolver(size_t min_literal_len, size_t max_gram_len)
+ : _min_literal_len(min_literal_len),
+ _max_gram_len(max_gram_len),
+ _histogram(kHashValues, 0) {}
+
+void DensitySolver::observe(std::string_view value) {
+ if (_min_literal_len < _max_gram_len || _max_gram_len < 2) {
Review Comment:
[P2] Make the adaptive defaults satisfiable. A normal `mode=sparse`/`auto`
tokenizer with no optional properties gets `max_gram=16`, while
`gram_index_min_literal_bytes` defaults to 12, so this returns for every row.
Finalization sees zero windows, keeps density 0.25, and still pays the 4 MiB
buffering/replay cost; the advertised default-on per-column solve never occurs.
Please align the defaults (or explicitly disable an impossible solve) and add a
writer test with all optional gram properties omitted.
##########
be/src/storage/index/snii/snii_index_writer.cpp:
##########
@@ -211,6 +281,20 @@ Status SniiIndexColumnWriter::add_values(const std::string
/*name*/, const void*
}
const auto* v = reinterpret_cast<const Slice*>(values);
for (size_t i = 0; i < count; ++i) {
+ if (_density_calibrating) {
+ // Held back, not dropped: this row is tokenized once the rate is
known. Feeding
+ // the solver here costs one linear pass over the row and retains
nothing of it.
+ _density_solver->observe(std::string_view(v->data, v->size));
Review Comment:
[P2] Feed the solver the same canonical bytes that will be indexed. This
observes the raw `Slice`, but replay truncates CHAR at the first NUL and
`GramExtractor` lowercases before hashing when `lower_case=true`. Because the
solved density is a quantile of byte-pair hashes, padding and case can select a
rate for a different corpus, missing the advertised coverage target or
overgrowing the index. Please share the extraction preprocessing here and add
CHAR-padding/lowercase calibration tests.
##########
be/src/storage/index/snii/snii_index_writer.cpp:
##########
@@ -211,6 +281,20 @@ Status SniiIndexColumnWriter::add_values(const std::string
/*name*/, const void*
}
const auto* v = reinterpret_cast<const Slice*>(values);
for (size_t i = 0; i < count; ++i) {
+ if (_density_calibrating) {
+ // Held back, not dropped: this row is tokenized once the rate is
known. Feeding
+ // the solver here costs one linear pass over the row and retains
nothing of it.
+ _density_solver->observe(std::string_view(v->data, v->size));
+ _density_sample.emplace_back(_rid, std::string(v->data, v->size));
Review Comment:
[P2] Bound and report the retained sample allocation, not just string
payload bytes. Each row adds a `pair<uint32_t, string>`, but the threshold and
reporter advance only by `v->size`; an all-empty sample therefore never reaches
the 4 MiB limit while the vector keeps growing, and millions of one-byte values
can retain tens of bytes of vector/SSO state per reported byte. The solver's
256 KiB histogram and longest-row scratch are invisible too. This defeats the
build-RAM/spill signal across concurrent writers. Please cap/account actual
retained capacity (and/or row count), including empty/tiny-row tests.
##########
be/src/storage/index/inverted/gram/gram_density.cpp:
##########
@@ -0,0 +1,125 @@
+// 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.
+
+#include "storage/index/inverted/gram/gram_density.h"
+
+#include <algorithm>
+
+#include "storage/index/inverted/gram/gram_extractor.h"
+
+namespace doris::segment_v2::gram {
+
+DensitySolver::DensitySolver(size_t min_literal_len, size_t max_gram_len)
+ : _min_literal_len(min_literal_len),
+ _max_gram_len(max_gram_len),
+ _histogram(kHashValues, 0) {}
+
+void DensitySolver::observe(std::string_view value) {
+ if (_min_literal_len < _max_gram_len || _max_gram_len < 2) {
+ return;
+ }
+ // Boundaries with a whole gram's room after them inside a window: a
boundary at k yields
+ // [k, k+max_gram_len) at worst, so k may not exceed len - max_gram_len.
That leaves
+ // `per_window` candidate positions in a window of the promised length.
+ const size_t per_window = _min_literal_len - _max_gram_len + 1;
+
+ size_t i = 0;
+ const size_t n = value.size();
+ while (i < n) {
+ // Only ASCII runs: they are the only bytes the extractor indexes, so
a window
+ // spanning anything else could never produce a gram.
+ if (static_cast<unsigned char>(value[i]) >= 0x80) {
+ ++i;
+ continue;
+ }
+ size_t j = i;
+ while (j < n && static_cast<unsigned char>(value[j]) < 0x80) {
+ ++j;
+ }
+ const std::string_view run = value.substr(i, j - i);
+ i = j;
+ if (run.size() < _min_literal_len) {
+ continue;
+ }
+ // Sliding minimum over the run's pair hashes, one window per start
position. The
+ // monotonic queue keeps this linear in the run rather than quadratic
in the window,
+ // which is what makes it affordable on the write path.
+ const size_t pairs = run.size() - 1;
+ _mono.clear();
+ size_t head = 0;
+ for (size_t p = 0; p < pairs; ++p) {
Review Comment:
[P2] Stop after the final real literal window. For an ASCII run of length
`n`, promised length `L`, and max gram `M`, there are `n-L+1` windows, but this
loop emits `n-L+M-1` minima because it continues through every byte pair—`M-2`
fictitious tail windows. With `n=L, M=4`, one real window is counted three
times, biasing the density quantile and its coverage promise. Limit `p` to
`run.size()-M` (or check `window_start+L`) and pin exact observed-window counts.
##########
be/src/storage/index/snii/format/format_constants.h:
##########
@@ -93,7 +93,14 @@ inline constexpr uint8_t kEnc = 1u << 1; // 0=slim /
1=windowed
inline constexpr uint8_t kHasSb = 1u << 2; // posting prelude includes
sub-block directory
inline constexpr uint8_t kHasChampion = 1u << 3; // v1 always 0
inline constexpr uint8_t kOffsetsRef = 1u << 4; // v1 always 0
-// bit5-7 reserved
+// The entry keeps its term key and df but carries no posting locator and no
payload: the
+// writer dropped a posting list too large for any reader to want.
bit0/bit1/bit2 are
+// meaningless when this is set, and nothing follows the term stats. A reader
that finds
+// this entry must treat the term as matching every document -- which is what
the query
+// side's cost gate already does for a df this high -- and must never confuse
it with a
+// term absent from the dictionary, which means the opposite: matching no
document.
+inline constexpr uint8_t kPostingDropped = 1u << 5;
Review Comment:
[P2] Fence this shortened entry from version-1 readers. The old decoder
ignores bit 5, treats these cleared low bits as a slim POD reference, and
unconditionally reads locator fields that this writer now omits, so it accepts
the v1 container and then reports truncation/body-length corruption. Ordinary
predicates can fall back to a scan, but automatic dropping makes
mixed-BE/shared-storage reads silently lose acceleration and leaves rollback
behavior dependent on that incidental corruption path. Please add a
required-feature/format gate plus a writer fleet fence, and cover
head-writer/frozen-old-reader behavior.
##########
be/src/storage/index/inverted/gram/gram_density.cpp:
##########
@@ -0,0 +1,125 @@
+// 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.
+
+#include "storage/index/inverted/gram/gram_density.h"
+
+#include <algorithm>
+
+#include "storage/index/inverted/gram/gram_extractor.h"
+
+namespace doris::segment_v2::gram {
+
+DensitySolver::DensitySolver(size_t min_literal_len, size_t max_gram_len)
+ : _min_literal_len(min_literal_len),
+ _max_gram_len(max_gram_len),
+ _histogram(kHashValues, 0) {}
+
+void DensitySolver::observe(std::string_view value) {
+ if (_min_literal_len < _max_gram_len || _max_gram_len < 2) {
+ return;
+ }
+ // Boundaries with a whole gram's room after them inside a window: a
boundary at k yields
+ // [k, k+max_gram_len) at worst, so k may not exceed len - max_gram_len.
That leaves
+ // `per_window` candidate positions in a window of the promised length.
+ const size_t per_window = _min_literal_len - _max_gram_len + 1;
+
+ size_t i = 0;
+ const size_t n = value.size();
+ while (i < n) {
+ // Only ASCII runs: they are the only bytes the extractor indexes, so
a window
+ // spanning anything else could never produce a gram.
+ if (static_cast<unsigned char>(value[i]) >= 0x80) {
+ ++i;
+ continue;
+ }
+ size_t j = i;
+ while (j < n && static_cast<unsigned char>(value[j]) < 0x80) {
Review Comment:
[P2] Exclude NUL-bearing evidence that the extractor cannot realize.
`observe` treats NUL as ASCII and includes pair hashes around it, while sparse
extraction drops every candidate gram containing NUL. For `L=3`, `M=2`, value
`A\0B`, the solver records one covered window although extraction emits none at
any density; NUL-heavy rows can therefore skew the global quantile away from
real searchable windows. Model the extractor's NUL rule here and add an exact
solver/extractor case.
##########
be/src/common/config.cpp:
##########
@@ -1324,6 +1324,39 @@ DEFINE_Int32(inverted_index_query_cache_shards, "256");
// inverted index match bitmap cache size
DEFINE_String(inverted_index_query_cache_limit, "10%");
+namespace {
+
+bool valid_gram_index_candidate_ratio(int32_t value) {
+ return value >= 0;
+}
+
+bool valid_gram_index_candidate_min_rows(int32_t value) {
+ return value >= 0;
+}
+
+} // namespace
+
+// Whether LIKE/REGEXP tries to compile a constant pattern into a gram boolean
query pushed down
+// to a gram-family inverted index (master switch).
+DEFINE_mBool(enable_gram_index_regexp, "true");
+
+// Cost gate for the gram boolean query: give up pruning above this share (in
basis points) of a
+// segment's rows, and only on segments of at least this many rows. Giving up
returns the segment's
+// whole docid range, so it can never drop a candidate row.
+DEFINE_mInt32(gram_index_max_candidate_ratio_bp, "15");
+DEFINE_Validator(gram_index_max_candidate_ratio_bp,
valid_gram_index_candidate_ratio);
+DEFINE_mInt32(gram_index_candidate_ratio_min_rows, "65536");
+
+// On by default: a configured density is wrong on every column but the one it
was chosen for,
+// and the solve costs one pass over a bounded sample.
+DEFINE_mBool(enable_gram_index_adaptive_density, "true");
+// 12 bytes at 95% of windows. Both are the promise rather than a tuning pair
-- what a user
+// may reasonably change is how short a literal they expect to find, not the
boundary rate.
+DEFINE_mInt32(gram_index_min_literal_bytes, "12");
+DEFINE_mInt32(gram_index_density_coverage_permille, "950");
Review Comment:
[P1] Validate this mutable permille before it reaches writers. Unlike the
adjacent ratio settings, `gram_index_density_coverage_permille` accepts `-1`;
every default-armed gram writer then calls the checked `cast_set<uint32_t>`
during finalization (even with no observed windows), throws `INTERNAL_ERROR`,
and fails the load until the live config is repaired. Values above 1000 are
also outside the documented share and silently clamp to maximum density. Please
register the semantic range validator and test startup plus live-update
rejection.
##########
regression-test/suites/inverted_index_p0/gram/test_gram_stop_gram.groovy:
##########
@@ -0,0 +1,192 @@
+// 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.
+
+// stop-gram end to end: an index built with the posting lists of very common
grams dropped
+// must still answer LIKE/REGEXP exactly, and must actually have dropped them.
+//
+// A dropped gram matches every document, so it can only ever widen the
candidate set the
+// index proposes, and the predicate is re-evaluated on those candidates. The
observable
+// contract is therefore equality: every pattern must return exactly what a
full scan
+// returns. The rows below are built so that the common grams really are
common -- a shared
+// prefix on every row -- while the patterns being searched for are rare,
which is the shape
+// where dropping matters.
+//
+// There is no switch to turn the feature off, so the control arm is the row
floor instead:
+// a segment under kHighDfDigestDivisor (2000) rows never drops a posting. The
same 4000 rows
+// are therefore loaded twice, once as a single segment (dropping active,
threshold
+// 4000 / 2000 * 3 = df 6) and once as five segments of 800 (dropping
inactive, every
+// posting kept). The two tables must agree on every answer, and the
single-segment index
+// must be much smaller -- that difference is the dropped postings, and it
also pins that
+// the floor holds.
+suite("test_gram_stop_gram", "p0") {
+ def waitAnalyzerInstalled = { String name ->
+ def deadline = System.currentTimeMillis() + 180_000
+ Exception lastNotFound = null
+ while (System.currentTimeMillis() < deadline) {
+ try {
+ sql """SELECT TOKENIZE('probe', '"analyzer"="${name}"')"""
+ return
+ } catch (Exception e) {
+ if (!e.message.contains("Policy not found")) {
+ throw e
+ }
+ lastNotFound = e
+ sleep(1000)
+ }
+ }
+ throw new IllegalStateException("analyzer ${name} was not installed on
BE", lastNotFound)
+ }
+
+ def backendId_to_backendIP = [:]
+ def backendId_to_backendHttpPort = [:]
+ getBackendIpHttpPort(backendId_to_backendIP, backendId_to_backendHttpPort)
+ def set_be_config = { key, value ->
+ for (String backend_id : backendId_to_backendIP.keySet()) {
+ def (code, out, err) =
update_be_config(backendId_to_backendIP.get(backend_id),
+ backendId_to_backendHttpPort.get(backend_id), key, value)
+ logger.info("update ${key}=${value}: code=${code}, out=${out},
err=${err}")
+ }
+ }
+
+ // The tables go first: a policy still referenced by a table left behind
by an earlier run
+ // cannot be dropped.
+ sql "DROP TABLE IF EXISTS test_gram_stop_gram_one"
+ sql "DROP TABLE IF EXISTS test_gram_stop_gram_five"
+ sql "DROP INVERTED INDEX ANALYZER IF EXISTS gram_stop_ana"
+ sql "DROP INVERTED INDEX TOKENIZER IF EXISTS gram_stop_tok"
+ sql """CREATE INVERTED INDEX TOKENIZER gram_stop_tok PROPERTIES (
+ "type"="ngram", "mode"="sparse", "min_gram"="3", "max_gram"="8",
"density"="0.5")"""
+ sql """CREATE INVERTED INDEX ANALYZER gram_stop_ana
+ PROPERTIES ("tokenizer"="gram_stop_tok")"""
+ waitAnalyzerInstalled("gram_stop_ana")
+
+ def rows = 4000
+ def values = []
+ for (int i = 0; i < rows; i++) {
+ // Every row shares "shared_prefix_", making its grams as common as a
gram gets. Four
+ // rows carry a rare marker (df 4, under the single segment's
threshold of 6, so its
+ // postings survive and the rare queries still filter); one row in
seven carries a
+ // mid-frequency tail whose grams sit above the line and are dropped.
+ def tail = (i % 1000 == 0) ? "rare_marker_${i}" : "filler_${i}"
+ values.add("(${i}, 'shared_prefix_common_text ${tail} tail_${i % 7}')")
+ }
+
+ def runAll = { String table, String label ->
+ def out = [:]
+ out["like_rare"] = sql "SELECT COUNT(*) FROM ${table} WHERE msg LIKE
'%rare_marker_%'"
+ out["like_common"] = sql "SELECT COUNT(*) FROM ${table} WHERE msg LIKE
'%shared_prefix_common%'"
+ out["regexp_rare"] = sql "SELECT COUNT(*) FROM ${table} WHERE msg
REGEXP 'rare_marker_[0-9]+'"
+ out["regexp_common"] = sql "SELECT COUNT(*) FROM ${table} WHERE msg
REGEXP 'shared_prefix_[a-z]+'"
+ out["regexp_mid"] = sql "SELECT COUNT(*) FROM ${table} WHERE msg
REGEXP 'tail_3\$'"
+ out["regexp_alt"] = sql "SELECT COUNT(*) FROM ${table} WHERE msg
REGEXP 'rare_marker_(0|1000|2000)\\\\b'"
+ out["regexp_absent"] = sql "SELECT COUNT(*) FROM ${table} WHERE msg
REGEXP 'no_such_token_anywhere'"
+ out["like_tail"] = sql "SELECT COUNT(*) FROM ${table} WHERE msg LIKE
'%tail_3'"
+ logger.info("${label}: ${out}")
+ return out
+ }
+
+ def createTable = { String table ->
+ sql "DROP TABLE IF EXISTS ${table}"
+ sql """CREATE TABLE ${table} (
+ `id` bigint NULL,
+ `msg` text NULL,
+ INDEX idx_msg (`msg`) USING INVERTED
+ PROPERTIES('analyzer'='gram_stop_ana',
'support_phrase'='false')
+ ) ENGINE=OLAP DUPLICATE KEY(`id`)
+ DISTRIBUTED BY HASH(`id`) BUCKETS 1
+ PROPERTIES('replication_num'='1',
'inverted_index_storage_format'='SNII',
+ 'disable_auto_compaction'='true')"""
+ }
+
+ // information_schema reports a fresh table's sizes with a delay, and 0
there means "not
+ // yet" rather than "empty".
+ def indexBytes = { String table ->
+ def deadline = System.currentTimeMillis() + 180_000
+ while (System.currentTimeMillis() < deadline) {
+ def r = sql """SELECT INDEX_LENGTH FROM information_schema.tables
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME =
'${table}'"""
+ def bytes = r.isEmpty() ? 0L : (r[0][0] as long)
+ if (bytes > 0) {
+ return bytes
+ }
+ sleep(3000)
+ }
+ throw new IllegalStateException("index size of ${table} never
reported")
+ }
+
+ def oneSegment = "test_gram_stop_gram_one"
+ def fiveSegments = "test_gram_stop_gram_five"
+ try {
+ sql "SET enable_sql_cache=false"
+ sql "SET enable_condition_cache=false"
+
+ // One batch keeps all 4000 rows in a single segment: above the row
floor, dropping
+ // is in effect.
+ createTable(oneSegment)
+ sql "INSERT INTO ${oneSegment} VALUES ${values.join(',')}"
+
+ // The same rows in five batches of 800: each segment is under the
floor, so every
+ // posting list is kept. This is what the index looked like before
dropping existed.
+ createTable(fiveSegments)
+ for (int b = 0; b < 5; b++) {
+ sql "INSERT INTO ${fiveSegments} VALUES ${values.subList(b * 800,
(b + 1) * 800).join(',')}"
+ }
+
+ // Ground truth, taken with the index out of the picture entirely.
+ sql "SET enable_inverted_index_query=false"
+ def scanned = runAll(oneSegment, "full scan")
+ sql "SET enable_inverted_index_query=true"
+
+ def dropped = runAll(oneSegment, "one segment, postings dropped")
+ def kept = runAll(fiveSegments, "five segments, postings kept")
+ scanned.each { name, value ->
+ assertEquals(value[0][0], dropped[name][0][0],
+ "index and scan disagree on ${name} with postings dropped")
+ assertEquals(value[0][0], kept[name][0][0],
+ "index and scan disagree on ${name} with postings kept")
+ }
+
+ // The postings really were dropped. Measured: the single-segment
index is 0.36 of
+ // the five-segment one. Had nothing been dropped it would be about
0.9 -- the only
+ // difference left would be four fewer per-segment dictionaries -- so
a factor of two
+ // separates the two outcomes with margin on both sides.
+ def droppedBytes = indexBytes(oneSegment)
+ def keptBytes = indexBytes(fiveSegments)
+ logger.info("index bytes: one segment ${droppedBytes}, five segments
${keptBytes}")
+ assertTrue(droppedBytes * 2 < keptBytes,
+ "the single-segment index (${droppedBytes} bytes) should be
far smaller than " +
+ "the five-segment one (${keptBytes} bytes): its common
postings were not dropped")
+
+ // The gate could otherwise mask a broken dropped-gram path by giving
up first, so
+ // check again with its fallback ratio disabled. The gate's primary
budget comes from
+ // the segment itself and cannot be switched off -- that is the point
of deriving it
+ // rather than configuring it -- but zeroing the ratio removes the one
arm that a
+ // configuration could have been hiding behind.
+ set_be_config("gram_index_max_candidate_ratio_bp", "0")
Review Comment:
[P2] Make this phase prove a physical dropped entry reaches the query path.
It repeats the preceding raw queries with the inverted-index cache still
enabled, so these calls can all reuse old bitmaps without exercising the
updated gate. Even on a miss, ratio zero disables only the fallback budget; the
digest budget can still widen common-only predicates before `_resolve()`.
Disable/bust the cache, add a retained+common literal that stays under budget,
and assert a profile/counter proving dictionary resolution (also fail if the
config update failed).
##########
be/src/storage/index/inverted/gram/gram_density.cpp:
##########
@@ -0,0 +1,125 @@
+// 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.
+
+#include "storage/index/inverted/gram/gram_density.h"
+
+#include <algorithm>
+
+#include "storage/index/inverted/gram/gram_extractor.h"
+
+namespace doris::segment_v2::gram {
+
+DensitySolver::DensitySolver(size_t min_literal_len, size_t max_gram_len)
+ : _min_literal_len(min_literal_len),
+ _max_gram_len(max_gram_len),
+ _histogram(kHashValues, 0) {}
+
+void DensitySolver::observe(std::string_view value) {
+ if (_min_literal_len < _max_gram_len || _max_gram_len < 2) {
+ return;
+ }
+ // Boundaries with a whole gram's room after them inside a window: a
boundary at k yields
+ // [k, k+max_gram_len) at worst, so k may not exceed len - max_gram_len.
That leaves
+ // `per_window` candidate positions in a window of the promised length.
+ const size_t per_window = _min_literal_len - _max_gram_len + 1;
+
+ size_t i = 0;
+ const size_t n = value.size();
+ while (i < n) {
+ // Only ASCII runs: they are the only bytes the extractor indexes, so
a window
+ // spanning anything else could never produce a gram.
+ if (static_cast<unsigned char>(value[i]) >= 0x80) {
+ ++i;
+ continue;
+ }
+ size_t j = i;
+ while (j < n && static_cast<unsigned char>(value[j]) < 0x80) {
+ ++j;
+ }
+ const std::string_view run = value.substr(i, j - i);
+ i = j;
+ if (run.size() < _min_literal_len) {
+ continue;
+ }
+ // Sliding minimum over the run's pair hashes, one window per start
position. The
+ // monotonic queue keeps this linear in the run rather than quadratic
in the window,
+ // which is what makes it affordable on the write path.
+ const size_t pairs = run.size() - 1;
+ _mono.clear();
+ size_t head = 0;
+ for (size_t p = 0; p < pairs; ++p) {
+ const uint16_t h =
+ boundary_hash16(static_cast<uint8_t>(run[p]),
static_cast<uint8_t>(run[p + 1]));
+ while (_mono.size() > head &&
+ boundary_hash16(static_cast<uint8_t>(run[_mono.back()]),
+ static_cast<uint8_t>(run[_mono.back() +
1])) >= h) {
+ _mono.pop_back();
+ }
+ _mono.push_back(static_cast<uint32_t>(p));
+ if (p + 1 < per_window) {
+ continue;
+ }
+ const size_t window_start = p + 1 - per_window;
+ while (_mono[head] < window_start) {
+ ++head;
+ }
+ const uint16_t min_hash =
boundary_hash16(static_cast<uint8_t>(run[_mono[head]]),
+
static_cast<uint8_t>(run[_mono[head] + 1]));
+ ++_histogram[min_hash];
+ ++_windows;
+ }
+ }
+}
+
+uint16_t DensitySolver::solve(uint32_t coverage_permille) const {
+ if (_windows == 0 || coverage_permille == 0) {
+ // No evidence, or nothing asked for. Stay where a configured default
would have been
+ // rather than inventing a rate from nothing.
+ return kMaxSolvedDensityPermille;
+ }
+ // A window is covered exactly when its minimum is below the threshold, so
the prefix sum
+ // of the histogram IS the coverage curve: walk it until the requested
share is reached and
+ // the threshold that achieves it -- and nothing larger -- is one past
that hash.
+ const uint64_t need = (static_cast<uint64_t>(coverage_permille) * _windows
+ 999) / 1000;
+ uint64_t seen = 0;
+ uint32_t threshold = kHashValues;
+ for (size_t h = 0; h < kHashValues; ++h) {
+ seen += _histogram[h];
+ if (seen >= need) {
+ // `is_boundary` compares strictly, so the threshold has to clear
this hash.
+ threshold = static_cast<uint32_t>(h) + 1;
+ break;
+ }
+ }
+ // GramExtractor derives its threshold as density_permille * 65536 / 1000
with integer
+ // division; round up or the resolved density can land a notch below what
was solved for.
+ uint32_t permille =
+ static_cast<uint32_t>((static_cast<uint64_t>(threshold) * 1000 +
65535) / 65536);
+ permille = std::clamp<uint32_t>(permille, kMinSolvedDensityPermille,
kMaxSolvedDensityPermille);
Review Comment:
[P2] Do not persist a ceiling-clamped value as though it met the requested
coverage. With valid `L=max_gram=12`, coverage 950, and rows `!!!!!!!!!!!!`,
the only pair hash is 60807 (density 928 required); this cap persists 500
(threshold 32768), so extraction emits no gram and real coverage is 0%, not
95%. This is active calibration, not a no-evidence fallback. Either permit the
required density or explicitly handle/report an unsatisfied target, and test a
corpus whose quantile exceeds the cap.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]