Repository navigation
Description
Search before asking
- I had searched in the issues and found no similar issues.
Version
master, 846d9b2.
What's Wrong?
__DORIS_COMMIT_TSO_COL__ stores a 0 placeholder on disk in a single-version segment; the real value is the rowset's commit_tso, supplied at read time by building a ConstantColumnReader instead of the on-disk reader. That substitution goes through a per-segment reader cache that ignores the constant:
Status ColumnReaderCache::get_column_reader(int32_t col_uid, ..., std::optional<Field> const_value) {
// Attempt to find in cache
if (auto cached = _lookup({col_uid, {}})) {
*column_reader = cached;
return Status::OK();
}be/src/storage/segment/column_reader_cache.cpp:96-105. On a hit the cached reader is returned and const_value is dropped, so whichever caller populates the entry first decides what every later caller gets. The cache is owned by the Segment and outlives a query.
The expression zone-map context builder is a caller that never passes a constant:
Status st = segment->get_column_reader(*tablet_column, &reader, read_options.stats,
&read_options.io_ctx);be/src/storage/segment/segment.cpp:129-130, and the page-level builder does the same at be/src/storage/segment/segment_iterator.cpp:3414-3415. The segment-level builder runs at be/src/storage/segment/segment.cpp:485-488, inside Segment::new_iterator, so it executes before the SegmentIterator exists and therefore before any Segment::new_column_iterator call, which is where the constant would otherwise be installed (be/src/storage/segment/segment.cpp:947-966).
The consequence is not limited to pruning. ConstantColumnReader::new_iterator returns a ConstantColumnIterator over the real value (be/src/storage/segment/column_reader.h:1093-1096); the on-disk reader returns the placeholder. Once the builder has cached the on-disk reader for that column, every subsequent read of that column on that segment yields 0 as row data, for as long as the segment stays cached, including for other queries.
Reaching it needs a pushed common expression that references the column and is zone-map evaluable, with no ColumnPredicate on the same column in the same query (a ColumnPredicate makes Segment::new_iterator install the constant first, at be/src/storage/segment/segment.cpp:422-437). IS NULL and IS NOT NULL already qualify (be/src/exprs/function/is_null.h:67, be/src/exprs/function/is_not_null.h:69), and a column-vs-column comparison would qualify once expression zone maps support that shape, which is proposed in #67774; such a comparison can never fall back to a ColumnPredicate, since those are column-vs-literal.
What You Expected?
A caller that asks for a constant-backed reader gets one, whatever another caller cached earlier, and a column whose on-disk value is a placeholder never returns that placeholder as row data.
How to Reproduce?
Not reproduced end to end; this is a static reading of the cache lookup, the two builder call sites, and their order inside Segment::new_iterator. A unit test on ColumnReaderCache is the direct check: request a column reader without const_value, then request the same column with one, and assert the second call returns a ConstantColumnReader.
Anything Else?
Correcting the two builder call sites is not sufficient on its own. Other callers build a bare StorageReadOptions without a tablet schema and so cannot pass a constant either, which the comment at be/src/storage/segment/segment.cpp:947-952 already notes for the MOW partial-update row fetch. The lookup itself is the place where the constant is dropped, so that is where the fix belongs.
Are you willing to submit PR?
- Yes I am willing to submit a PR!
Code of Conduct
- I agree to follow this project's Code of Conduct
Activity
Doris-Breakwater commentedon Sep 15, 2026
Breakwater initial analysis
Verdict: Confirmed code-level correctness defect on
masterat846d9b2ebfc595ddfd77579447db16a674c901f7. The cache contract is inconsistent with the newconst_valueinput: a cold lookup uses it to create a different reader type, while a hit returns the existing reader without considering it. This is sufficient to establish the root cause statically. The exact SQL-level trigger and production frequency are not yet demonstrated, so those parts of the impact remain unverified.Verified facts
ColumnReaderCacheKeycontains only(column uid, path).get_column_readerperforms that lookup before inspectingconst_value; only the miss path forwardsconst_valuetoColumnReader::create, which constructs aConstantColumnReader.build_segment_zonemap_contextrequests the ordinary reader. It is called inSegment::new_iteratorbefore theSegmentIteratoris constructed and initialized. Later,Segment::new_column_iteratorsupplies the rowsetcommit_tso, but will receive the already-cached on-disk reader. The page-level expression zone-map path also requests the ordinary reader.0, and the existing segment test explicitly writes0for this hidden column.ConstantColumnReader::new_iteratoris the component that substitutes the read-time value. Therefore, after the ordinary reader wins the cache entry, later data reads use the on-disk iterator and can emit0; this is not confined to pruning.ColumnReaderCacheis owned by the cachedSegment, so a contaminated entry can survive the initiating query until that segment is evicted. This is read-time result corruption; there is no evidence here of persisted data corruption.new_column_iteratorconstant read and aColumnPredicatepruning path, but do not cover an ordinary-reader cache hit followed by a constant request.IS NULL/IS NOT NULLare zone-map-evaluable and can prime the expression path, although an end-to-end plan proving access to this hidden column has not been supplied.Assessment / priority
This should be triaged as a BE storage correctness bug, with high priority before merging the related expression-zone-map expansion. The trigger is conditional, but the failure is deterministic once a non-constant caller populates the per-segment entry first. Disabling expression zone-map filtering avoids the demonstrated priming path, and disabling segment caching limits cross-query persistence, but neither is a complete correctness fix because other non-constant callers exist and the within-query ordering remains possible.
Information still needed to quantify user-visible impact
EXPLAIN VERBOSE.ExprZoneMapFilteredSegments,ExprZoneMapFilteredPages, andExprZoneMapUnusableEvalscounters.enable_expr_zonemap_filterandenable_segment_cache, the expected and actual rows, and whether repeating the query or disabling the segment cache changes the result.Recommended next steps
const_valueparticipates in lookup semantics (for example, a separate constant-reader key/path or a non-cached constant reader after preserving column-existence validation). Updating only the two expression zone-map callers is insufficient._insert_locked_nocheckdoes not replace/remove an existing LRU node, so that approach can leave duplicate list nodes and inconsistent map/accounting state.ColumnReaderCachetest forordinary request -> same uid with const_value, and assert both the reader type and values returned by its iterator. Also extend the existing commit-TSO segment test to prime the ordinary reader beforenew_column_iterator, then verify that the realcommit_tso, not0, is read.The reporter is willing to submit a PR; the proposed cache-level scope is appropriate.
Breakwater-GitHub-Analysis-Slot: slot_d396904bac92
Doris-Breakwater commentedon Sep 15, 2026
Breakwater-GitHub-Analysis-Slot: slot_d396904bac92
Initial maintainer analysis
Assessment: this is a valid, high-confidence BE correctness defect at the reported
mastercommit846d9b2ebfc595ddfd77579447db16a674c901f7. The cache-level contract violation is deterministic from source: a request carryingconst_valuecan receive an already-cached physical reader. That can expose the stored0instead of the rowsetcommit_tso. The issue should be accepted as a storage/table-stream correctness bug; it is an in-memory read corruption risk, not evidence of on-disk corruption.The live issue currently has no labels, assignee, milestone, comments, or linked PR.
kind/bugis appropriate; the repository's normal storage/table-stream area label should also be applied if one exists.Verified facts
ColumnReaderCache::get_column_readerlooks up only(col_uid, empty_path)and returns immediately on a hit.const_valuereachesColumnReaderOptionsonly on a miss.ColumnReader::createconstructs aConstantColumnReaderonly when that option survives to creation. Thus the proposed cache unit-test order (physical request, then constant request) necessarily returns the same physical reader today.The reported ordering is correct. The segment expression-zone-map builder requests a reader without a constant (builder call) and is run before the
SegmentIteratoris constructed (Segment::new_iterator).SegmentIterator::init()creates its column iterators only afterwards (initialization, column-reader request). The page-zone-map path also makes a bare request (source).Segment::new_column_iteratorderives the real constant for the commit-TSO UID on a single-version rowset. A successful constant reader creates aConstantColumnIteratorover that value (source). If the cache returns the physical reader instead, this replacement is lost. The later_update_tso_col_if_neededsafeguard is limited toread_row_binlogand the binlog TSO ordinal (source); it is not a fallback for the commit-TSO constant-reader path.Cross-query persistence is real but bounded. The reader cache is owned by
Segment, while query reads use the segment LRU by default (segment loading, query default). A bad entry can therefore affect later queries until that column-reader entry or the segment is evicted. It is not guaranteed to last for the entire segment-cache residency because the per-segment column cache has its own LRU limit.Existing tests verify direct
ColumnReader::createand constant-iterator behavior (test), butcolumn_reader_cache_test.cppdoes not cover changing from a physical request to a constant request for the same key.Reachability qualification and missing evidence
The cache defect itself does not need logs or a Doris profile to confirm. The exact current end-to-end trigger and user-visible blast radius are not yet demonstrated, however:
IS NULL/IS NOT NULLexpressions are normally normalized intoColumnPredicates (source), and the built-in time-travel filter is also a column-vs-literal comparison. Those ordinary forms install/request the constant in the earlier predicate loop unless the segment was already warmed with a physical reader.common_expr_ctxs_push_downwith no same-columnColumnPredicate. A compound expression can plausibly have that shape, but no SQL or serialized plan was supplied.For an end-to-end confirmation, please add: table DDL and binlog/time-travel properties; the exact SQL sequence (including the cache-warming query);
EXPLAIN VERBOSE; confirmation that the affected rowset is single-version with a nonzerocommit_tso; and, if available, a query profile showing expression-zone-map pushdown/counters. This evidence is useful for impact qualification, not a prerequisite for fixing the deterministic cache bug.Required fix properties and tests
const_valuemust be authoritative even when the UID is already cached. Upgrade/replace the cached physical reader atomically and return the constant reader. Because the constant is segment/rowset intrinsic, later bare requests should not downgrade it._insert_locked_nocheckfor an existing key: it appends a second LRU node and overwrites only the map iterator, leaving stale LRU/accounting state. Add a true same-key upsert/replace operation under_cache_mutex, and define the concurrent physical-vs-constant winner explicitly.[0,0]. The segment- and page-zone-map builders must request the same constant, or constant-reader selection must be centralized in an API where those callers cannot omit the rowset constant. Bare internal readers are why the cache-level upgrade is still necessary.ColumnPredicate, proves the first zone-map evaluation uses the realcommit_tso, and then proves row reads return the same nonzero value. Add a second-query/cache-reuse case. If [Enhancement](zonemap) Support column-vs-column comparisons in expression zone map pruning #67774 lands, include its column-vs-column shape as an additional regression.LuciferYang commentedon Sep 15, 2026
Correcting my own trigger claim, which the analysis above is right to qualify.
I wrote that
IS NULL/IS NOT NULLalready qualify as pushed common expressions. Zone-map evaluability was the wrong thing to check.ScanLocalStateBase::_normalize_is_null_predicate(be/src/exec/operator/scan_operator.cpp:967-1011) convertsis_null_predinto a column range, so such a predicate normally leaves the common-expression set and arrives as aColumnPredicate, which makes the earlier predicate loop install the constant reader first. So no currently merged shape is demonstrated to reach this; the reachable one is the column-vs-column comparison from #67774, which can never become aColumnPredicate.Two other corrections to my wording: the bad entry lasts until the per-segment column cache evicts it or the segment is evicted, not necessarily the whole segment residency; and the defect is the dropped constant on the lookup, independent of any trigger.
On the fix: the same-key replacement trap named in point 2 is real.
_insert_locked_nocheckpushes a second LRU node and only overwrites the map iterator, and eviction erases_cache_map[tail->key], which can drop the live entry of that key. My patch makes it a same-key in-place update instead of a second insert. I will extend the unit tests to assert the constant reader's zone map and iterator output rather than only its type, and add the concurrent physical-versus-constant case.Point 3 is the reason #67995 exists separately: a cache upgrade cannot repair a zone-map evaluation that already consumed the physical
[0,0], so the builders have to request the constant themselves.LuciferYang commentedon Sep 15, 2026
One more piece of evidence, which I should have found before filing: the hazard is already known and worked around at another call site.
segment_zone_maps_can_answer_aggwalks every column and requests a reader without a constant, and it skips exactly one ordinal for exactly this reason (be/src/storage/segment/segment.cpp:148-160):So "a bare request for that column poisons the cache for every later read" is not my inference; it is the documented reason a loop three functions above the zone-map builders avoids the column. The two expression zone-map builders make the same bare request and do not skip it.
That also means the fix at the cache level makes this call site's skip unnecessary rather than wrong, so I will leave it alone in the cache PR and let #67995 decide what those loops should do with placeholder columns.
LuciferYang commentedon Sep 15, 2026
Opened #68018.