github-actions[bot] commented on code in PR #67292:
URL: https://github.com/apache/doris/pull/67292#discussion_r4324631642


##########
be/src/storage/segment/segment_iterator.cpp:
##########
@@ -1912,7 +2021,9 @@ Status SegmentIterator::_init_column_iterators() {
                     // If the col is predicate column, then should read the 
last page to check
                     // if the column is full dict encoding
                     .is_predicate_column = _column_states[cid].has_predicate(),
-                    .file_reader = _file_reader.get(),
+                    .file_reader = _use_read_ahead_for_column(cid)

Review Comment:
   [P2] Preserve dictionary-page corruption recovery
   
   With read-ahead enabled, a dictionary-encoded scalar column whose dictionary 
page is absent from Page Cache but corrupt in File Cache now fails the query 
even when the remote object is healthy. `_read_dict_data()` passes this wrapper 
to PageIO, whose cache eviction/redownload path requires a 
`CachedRemoteFileReader` dynamic cast. That cast fails, and 
`_read_data_page()`'s new retry ends before dictionary loading. Preserve the 
original corruption-recovery path for dictionary pages too, and cover a cached 
dictionary checksum failure.



##########
tools/read_io_trace/analyze.py:
##########
@@ -0,0 +1,342 @@
+#!/usr/bin/env python3
+# 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.
+
+"""Offline analysis of opt-in READ_IO_TRACE records. All lengths are bytes."""
+
+import argparse
+import collections
+import gzip
+import itertools
+import json
+import math
+import sys
+
+
+SOURCES = ("foreground", "hole_fill", "other")
+
+
+def distribution(values):
+    """Exact nearest-rank percentiles; an empty sample is missing evidence, 
not zero latency."""
+    values = sorted(values)
+    if not values:
+        return {"count": 0}
+    return {"count": len(values), "sum": sum(values), "min": values[0],
+            "p50": values[math.ceil(len(values) * 0.50) - 1],
+            "p95": values[math.ceil(len(values) * 0.95) - 1],
+            "p99": values[math.ceil(len(values) * 0.99) - 1], "max": 
values[-1]}
+
+
+def summarize_timings(events, max_examples=5):
+    """Durations are accumulated thread wall time, not query elapsed time or 
CPU time."""
+    for event in events:
+        if event["start_ns"] <= 0 or event["time_ns"] < event["start_ns"]:
+            raise ValueError(f"invalid timing interval: {event['event']}")
+    details = collections.defaultdict(list)
+    for event in events:
+        for name, value in event.get("details", {}).items():
+            details[name].append(value)
+    slowest = sorted(events, key=lambda item: item["time_ns"] - 
item["start_ns"], reverse=True)
+    fields = ("file", "id", "parent_id", "offset", "size", "start_ns", 
"time_ns", "outcome")
+    return {
+        "duration_ns": distribution(event["time_ns"] - event["start_ns"] for 
event in events),
+        "details": {name: distribution(values) for name, values in 
sorted(details.items())},
+        "outcomes": dict(collections.Counter(event["outcome"] for event in 
events)),
+        "slowest": [{**{name: event[name] for name in fields},
+                     "details": event.get("details", {})} for event in 
slowest[:max_examples]],
+    }
+
+
+def summarize_queue_scans(events, max_examples):
+    summary = summarize_timings(events, max_examples)
+    buckets = collections.defaultdict(list)
+    for event in events:
+        size = event["details"]["queue_size"]
+        bucket = "0-63" if size < 64 else "64-255" if size < 256 else 
"256-1023" if size < 1024 else "1024+"
+        buckets[bucket].append(event)
+    summary["by_queue_size"] = {name: summarize_timings(items, 0)
+                                for name, items in sorted(buckets.items())}
+    return summary
+
+
+def source_group(event):
+    source = event["source"]
+    return "foreground" if source in ("read_ahead", "sync") else source
+
+
+def load_events(paths):
+    """Read JSONL (or legacy INFO) files and check completeness before query 
filtering."""
+    events = {}
+    checkpoints = {}
+    jsonl_processes = set()
+    warnings = []
+    for path in paths:
+        opener = gzip.open if path.endswith(".gz") else open
+        with opener(path, "rt", encoding="utf-8") as stream:
+            for number, line in enumerate(stream, 1):
+                marker = "READ_IO_TRACE "
+                legacy = not line.lstrip().startswith("{")
+                if legacy:
+                    if "READ_IO_TRACE_LIMIT " in line:
+                        warnings.append("Trace event limit reached; capture is 
incomplete")
+                    if marker not in line:
+                        continue
+                try:
+                    event = json.loads(line.split(marker, 1)[1] if legacy else 
line)
+                    if event.get("kind") == "read_io_trace_status":
+                        if event["v"] != 1:
+                            raise ValueError("unsupported trace checkpoint 
version")
+                        previous = checkpoints.get(event["process"], (0, 0))
+                        checkpoints[event["process"]] = (
+                            max(previous[0], event["written_events"]),
+                            max(previous[1], event["dropped_events"]))
+                        continue
+                    if event["v"] != 1 or source_group(event) not in SOURCES:
+                        raise ValueError("unsupported trace version or source")
+                    key = (event["process"], event["seq"])
+                    if key in events and events[key] != event:
+                        raise ValueError("conflicting records for the same 
process/sequence")
+                    events[key] = event  # rotated/symlinked log copies must 
not count twice
+                    if not legacy:
+                        jsonl_processes.add(event["process"])
+                except (ValueError, KeyError) as error:
+                    raise ValueError(f"{path}:{number}: {error}") from error
+    sequences = collections.defaultdict(list)
+    for process, sequence in events:
+        sequences[process].append(sequence)
+    for process, values in sequences.items():
+        missing = max(values) - min(values) + 1 - len(values)
+        if missing:
+            warnings.append(f"{process}: {missing} missing trace events inside 
capture")
+    for process, (written, dropped) in checkpoints.items():
+        if dropped:
+            warnings.append(f"{process}: trace writer lost {dropped} events; 
capture is incomplete")
+        captured = len(sequences[process])
+        if captured != written:
+            warnings.append(f"{process}: checkpoint reports {written} written 
events, "
+                            f"but files contain {captured}; copy the complete 
drained trace")
+    for process in jsonl_processes - checkpoints.keys():
+        warnings.append(f"{process}: missing flush checkpoint; capture may 
have an incomplete tail")
+    if not events:
+        warnings.append("No trace events found")
+    return list(events.values()), sorted(set(warnings))
+
+
+def overlap_example(left, right, foreground, hole, lifecycle):
+    if foreground["time_ns"] <= hole["start_ns"]:
+        order = "foreground_completed_before_hole_get"
+    elif hole["time_ns"] <= foreground["start_ns"]:
+        order = "hole_completed_before_foreground_get"
+    else:
+        order = "overlapping_get_lifetimes"
+    range_id, task_id = foreground["parent_id"], hole["parent_id"]
+    relevant = [
+        event for event in lifecycle
+        if (event["event"] == "range_writeback" and range_id and event["id"] 
== range_id)
+        or (event["event"] in ("hole_queued", "hole_active", "hole_done")
+            and task_id and event["id"] == task_id)
+        or (event["event"] == "fragment_ignored_active" and task_id
+            and event["id"] == task_id and event["parent_id"] == range_id)
+    ]
+    fields = ("event", "id", "parent_id", "offset", "size", "time_ns", 
"outcome")
+    return {
+        "file": foreground["file"],
+        "overlap": [left, right],
+        "bytes": right - left,
+        "order": order,
+        "foreground_get": {key: foreground[key] for key in (
+            "id", "parent_id", "source", "offset", "size", "start_ns", 
"time_ns")},
+        "hole_get": {key: hole[key] for key in (
+            "id", "parent_id", "offset", "size", "start_ns", "time_ns")},
+        "lifecycle": [{key: event[key] for key in fields}
+                      for event in sorted(relevant, key=lambda item: 
(item["time_ns"], item["seq"]))],
+    }
+
+
+def summarize_query(events, max_examples=5):
+    """Sweep interval endpoints: O(N log N), no pairwise range comparisons.
+
+    Duplicate bytes count each extra successful transfer once. Within-source 
duplication plus
+    cross-source duplication partitions that total even for three or more 
overlapping GETs.
+    """
+    successful = [event for event in events if event["event"] == "s3_get"
+                  and event["outcome"] == "success"]
+    failed = [event for event in events if event["event"] == "s3_get"
+              and event["outcome"] != "success"]
+    by_file = collections.defaultdict(list)
+    lifecycle = collections.defaultdict(list)
+    for event in successful:
+        if event["bytes"] != event["size"] or event["size"] <= 0:
+            raise ValueError("success GET must have positive size and a full 
response")
+        by_file[event["file"]].append(event)
+    for event in events:
+        if event["event"] != "s3_get":
+            lifecycle[event["file"]].append(event)

Review Comment:
   [P3] Join packed-file lifecycle events by their IDs
   
   For packed native segments, `S3FileReader` records GETs under the physical 
packed-object URI, while range/writeback/hole events use the logical segment 
path retained by `PackedFileReader`. Grouping lifecycle events by `file` here 
and later selecting only `lifecycle[file]` for a physical GET excludes those 
events before `overlap_example()` can match their range/task IDs. Packed-file 
overlap examples therefore have empty lifecycle details despite complete 
captured IDs. Join lifecycle records by their process-scoped IDs while 
retaining physical-object grouping for byte totals, and cover differing 
logical/physical paths.



##########
be/src/io/cache/cached_remote_file_reader.cpp:
##########
@@ -132,6 +135,34 @@ 
CachedRemoteFileReader::CachedRemoteFileReader(FileReaderSPtr remote_file_reader
     }
 }
 
+std::shared_ptr<RangeCacheWriteback> 
CachedRemoteFileReader::make_range_cache_writeback(
+        const IOContext& io_context, PartialBlockWritebackManager* 
partial_block_manager) const {
+    DORIS_CHECK(_cache != nullptr);
+    DORIS_CHECK(partial_block_manager != nullptr);
+    if (_resolve_cache_write_mode(&io_context) == CacheWriteMode::NO_WRITE ||
+        use_remote_only_on_cache_miss(&io_context)) {
+        return nullptr;
+    }
+    auto* write_manager = _cache->async_write_manager();
+    DORIS_CHECK(write_manager != nullptr);
+    CacheContext cache_context(&io_context);
+    return std::make_shared<RangeCacheWriteback>(RangeCacheWritebackOptions {
+            .write_manager = write_manager,
+            .partial_block_manager = partial_block_manager,
+            .inflight_index = 
config::enable_async_file_cache_write_inflight_write_buffer_index
+                                      ? _cache->inflight_write_buffer_index()
+                                      : nullptr,
+            // The wrapper closes the inner reader on destruction. Keep the 
wrapper as the owner
+            // while background hole fills still read directly through the 
inner reader.
+            .source_reader = FileReaderSPtr(shared_from_this(), 
_remote_file_reader.get()),
+            .cache_hash = _cache_hash,
+            .file_size = size(),
+            .block_size = 
static_cast<size_t>(config::file_cache_each_block_size),
+            .admission_ctx = 
CacheAdmissionContext::from_cache_context(cache_context, _tablet_id),

Review Comment:
   [P2] Preserve the query cache-write budget for consumed ranges
   
   For a native cloud query with peer reads enabled and a positive 
`file_cache_query_limit_bytes`, the original reader selects synchronous writes 
and charges new blocks through 
`split_range_into_cells()->try_admit_cache_write()`, even when async writes are 
enabled globally. Read-ahead instead fetches with `NO_WRITE`, then submits 
through this admission snapshot, which drops the limiter and its admission 
flag; neither complete-block nor hole-fill writeback charges it. If the limiter 
has not tripped before this context is created, a long scalar scan can populate 
File Cache beyond its query budget. Preserve that admission policy with 
ownership safe for deferred work.



-- 
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]

Reply via email to