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

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 60818fd6716 [fix](filecache) Keep HTTP files out of the file cache and 
key external files by storage (#68847)
60818fd6716 is described below

commit 60818fd6716c90f36ff434ced6b31fb4b42640fa
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Mon Oct 12 14:29:11 2026 +0800

    [fix](filecache) Keep HTTP files out of the file cache and key external 
files by storage (#68847)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #68797, #66932
    
    Problem Summary:
    
    **In short.** The BE file cache can return the bytes of a different file
    for two kinds of external reads. An `http()` URL whose content changes
    keeps being served from blocks cached from the old content. Two object
    storage endpoints, or two HDFS clusters, that hold the same path with
    the same modification time share cache blocks. Both happen on master
    whenever a session turns on `enable_file_cache`, and on branch-4.x,
    where the cache is on by default. #68797 turns the cache on by default
    on master and is blocked on these two cases. This PR keeps HTTP
    responses out of the file cache and adds the storage to the cache key of
    external files.
    
    **Background.** `CachedRemoteFileReader` keys an external file as
    `path:mtime`: the path its reader reports plus the modification time
    from the file listing. The key is meant to change whenever the file
    does. `FileFactory` wraps S3, HDFS and HTTP readers in the cache when
    the query enables it. #66932 already exempted chunked HTTP responses
    (the `cdc_stream` table function), whose content is a stream.
    
    **The problem, and what it cost.**
    
    - **HTTP has no version.** `HttpTableValuedFunction` lists a URL with
    modification time 0, so every response of that URL gets the key `url:0`.
    When the content behind the URL changes, a later query reads blocks
    cached from the earlier content. If the length did not change, nothing
    fails and the query silently returns the old rows; if it did, old and
    new bytes can be mixed.
    - **The key does not name the storage.** An S3 reader reports
    `s3://bucket/key`, without the endpoint. An HDFS reader reports the path
    with the name node stripped (`convert_path`). Two S3-compatible
    endpoints (for example two MinIO deployments) holding the same bucket
    and key, or two HDFS clusters holding the same path, get the same key as
    soon as the modification times match. S3 reports them in seconds, so
    files written by the same job in the same second match. A query on one
    storage can then read blocks of the other storage's file.
    
    **How this PR fixes it.**
    
    - `FileFactory` no longer wraps HTTP readers in the file cache. This
    covers every HTTP source read through `FileFactory`, including `hf://`
    URLs, and replaces the chunked-response check from #66932, which this
    rule includes.
    - `FileReaderOptions` gets `cache_key_function`, which builds the cache
    key of an external file from its path and modification time, so the code
    that opens the file defines its key. `FileFactory` sets it for S3
    readers to put the S3 endpoint (`AWS_ENDPOINT`) in front of the path,
    and for HDFS readers the name node (`fs_name`): the key becomes
    `<storage>:path:mtime`. `CachedRemoteFileReader` calls the function when
    it is set; readers that do not set it, such as broker reads, keep
    `path:mtime`.
    
    **Results.**
    
    | Case | Before | After |
    |---|---|---|
    | `http()` URL whose content was replaced, file cache on | second query
    can return rows of the old content | always reads the current content;
    HTTP data is not cached |
    | Same bucket, key and mtime on two S3-compatible endpoints | one cache
    entry, shared | one entry per endpoint |
    | Same path and mtime on two HDFS clusters | one cache entry, shared |
    one entry per name node |
    | Existing cached blocks of external S3 / HDFS files after upgrade | - |
    not found under the new key; the first reads go to the storage again and
    the old blocks age out |
    
    **Classes, and how they call each other.**
    
    ```
    FileScanner / format readers
      └─ FileFactory::create_file_reader(system_properties, file_description, 
reader_options)
           ├─ FILE_S3   : options.cache_key_function = endpoint:path:mtime ─┐
           ├─ FILE_HDFS : options.cache_key_function = fs_name:path:mtime ──┤
           │                                       
create_cached_file_reader(reader, options)
           │                                         └─ 
CachedRemoteFileReader::_init_external_table_cache
           │                                              key = 
cache_key_function(path, mtime)
           │                                                    (path:mtime 
when it is not set)
           └─ FILE_HTTP : HttpFileReader, not wrapped in the file cache
    ```
---
 be/src/io/cache/cached_remote_file_reader.cpp      |  7 ++-
 be/src/io/file_factory.cpp                         | 42 +++++++++------
 be/src/io/fs/file_reader.h                         |  5 ++
 .../io/cache/cached_remote_file_reader_test.cpp    | 60 ++++++++++++++++++++++
 be/test/io/fs/http_file_reader_test.cpp            | 19 +++++++
 .../data/external_table_p0/tvf/test_http_tvf.out   |  6 +++
 .../external_table_p0/tvf/test_http_tvf.groovy     | 32 +++++++++++-
 7 files changed, 151 insertions(+), 20 deletions(-)

diff --git a/be/src/io/cache/cached_remote_file_reader.cpp 
b/be/src/io/cache/cached_remote_file_reader.cpp
index ef0c395b4c1..18eb7c3d00d 100644
--- a/be/src/io/cache/cached_remote_file_reader.cpp
+++ b/be/src/io/cache/cached_remote_file_reader.cpp
@@ -142,8 +142,11 @@ void CachedRemoteFileReader::_init_doris_table_cache() {
 }
 
 void CachedRemoteFileReader::_init_external_table_cache(const 
FileReaderOptions& opts) {
-    // Use path and modification time to build cache key.
-    std::string unique_path = fmt::format("{}:{}", path().native(), 
opts.mtime);
+    // Use path and modification time to build cache key, unless the creator 
of this reader names
+    // the file by more than its path.
+    std::string unique_path = opts.cache_key_function
+                                      ? 
opts.cache_key_function(path().native(), opts.mtime)
+                                      : fmt::format("{}:{}", path().native(), 
opts.mtime);
     _cache_hash = BlockFileCache::hash(unique_path);
     if (opts.cache_base_path.empty()) {
         // If cache path is not specified by session variable, choose randomly.
diff --git a/be/src/io/file_factory.cpp b/be/src/io/file_factory.cpp
index a29ac314545..c17742d9e2e 100644
--- a/be/src/io/file_factory.cpp
+++ b/be/src/io/file_factory.cpp
@@ -17,6 +17,7 @@
 
 #include "io/file_factory.h"
 
+#include <fmt/format.h>
 #include <gen_cpp/PaloInternalService_types.h>
 #include <gen_cpp/PlanNodes_types.h>
 #include <gen_cpp/Types_types.h>
@@ -52,13 +53,25 @@
 #include "service/backend_options.h"
 #include "util/s3_uri.h"
 #include "util/s3_util.h"
-#include "util/string_util.h"
 #include "util/uid_util.h"
 
 namespace doris {
 
 constexpr std::string_view RANDOM_CACHE_BASE_PATH = "random";
 
+// The file cache keys an external file by its path and modification time, but 
a path names a file
+// only within one storage: the same bucket and key on two object storage 
endpoints, or the same
+// path on two HDFS clusters, are different files. Put the storage in front of 
the key.
+static io::FileReaderOptions with_storage_in_cache_key(const 
io::FileReaderOptions& options,
+                                                       std::string storage) {
+    auto keyed = options;
+    keyed.cache_key_function = [storage = std::move(storage)](const 
std::string& path,
+                                                              int64_t mtime) {
+        return fmt::format("{}:{}:{}", storage, path, mtime);
+    };
+    return keyed;
+}
+
 io::FileReaderOptions FileFactory::get_reader_options(const TQueryOptions& 
option,
                                                       const 
io::FileDescription& fd) {
     io::FileReaderOptions opts {
@@ -232,10 +245,11 @@ Result<io::FileReaderSPtr> 
FileFactory::_create_file_reader_internal(
                 system_properties.properties, s3_uri, &s3_conf));
         auto client_holder = 
std::make_shared<io::ObjClientHolder>(s3_conf.client_conf);
         RETURN_IF_ERROR_RESULT(client_holder->init());
+        auto options = with_storage_in_cache_key(reader_options, 
s3_conf.client_conf.endpoint);
         return io::S3FileReader::create(std::move(client_holder), 
s3_conf.bucket, s3_uri.get_key(),
                                         file_description.file_size, profile)
-                .and_then([&](auto&& reader) {
-                    return io::create_cached_file_reader(std::move(reader), 
reader_options);
+                .and_then([&options](auto&& reader) {
+                    return io::create_cached_file_reader(std::move(reader), 
options);
                 });
     }
     case TFileType::FILE_HDFS: {
@@ -248,10 +262,12 @@ Result<io::FileReaderSPtr> 
FileFactory::_create_file_reader_internal(
         }
         
RETURN_IF_ERROR_RESULT(ExecEnv::GetInstance()->hdfs_mgr()->get_or_create_fs(
                 system_properties.hdfs_params, *fs_name, &handler));
+        // The reader's path drops the name node, so the cache key takes it 
from here.
+        auto options = with_storage_in_cache_key(reader_options, *fs_name);
         return io::HdfsFileReader::create(file_description.path, 
handler->hdfs_fs, *fs_name,
-                                          reader_options)
-                .and_then([&](auto&& reader) {
-                    return io::create_cached_file_reader(std::move(reader), 
reader_options);
+                                          options)
+                .and_then([&options](auto&& reader) {
+                    return io::create_cached_file_reader(std::move(reader), 
options);
                 });
     }
     case TFileType::FILE_BROKER: {
@@ -272,17 +288,11 @@ Result<io::FileReaderSPtr> 
FileFactory::_create_file_reader_internal(
                 });
     }
     case TFileType::FILE_HTTP: {
-        auto options = reader_options;
-        auto chunk_response = 
system_properties.properties.find("http.enable.chunk.response");
-        if (chunk_response != system_properties.properties.end() &&
-            (iequal(chunk_response->second, "true") || chunk_response->second 
== "1")) {
-            options.cache_type = io::FileCachePolicy::NO_CACHE;
-        }
+        // HTTP responses never enter the file cache. The cache key names an 
external file by its
+        // path and modification time, but an HTTP source has no modification 
time, so a URL whose
+        // content changes would keep serving the blocks cached from the old 
content.
         return io::HttpFileReader::create(file_description.path, 
system_properties.properties,
-                                          options, profile)
-                .and_then([&options](auto&& reader) {
-                    return io::create_cached_file_reader(std::move(reader), 
options);
-                });
+                                          reader_options, profile);
     }
     default:
         return ResultError(
diff --git a/be/src/io/fs/file_reader.h b/be/src/io/fs/file_reader.h
index 6633841dc45..bc736c4a764 100644
--- a/be/src/io/fs/file_reader.h
+++ b/be/src/io/fs/file_reader.h
@@ -20,6 +20,7 @@
 #include <butil/macros.h>
 #include <stddef.h>
 
+#include <functional>
 #include <memory>
 #include <string>
 
@@ -81,6 +82,10 @@ struct FileReaderOptions {
     // Storage resource id of the remote file system. Used by peer fill to 
reconstruct
     // the source file system without scanning tablet rowsets on the peer.
     std::string storage_resource_id;
+    // Builds the file cache key of an external file from its path and 
modification time. The
+    // creator of the reader sets it when the path alone does not name the 
file, e.g. FileFactory
+    // adds the object storage endpoint or the HDFS name node. When it is 
empty the key is path:mtime.
+    std::function<std::string(const std::string& path, int64_t mtime)> 
cache_key_function {};
 
     static const FileReaderOptions DEFAULT;
 };
diff --git a/be/test/io/cache/cached_remote_file_reader_test.cpp 
b/be/test/io/cache/cached_remote_file_reader_test.cpp
index 6fe9bac5d60..98edb308a7b 100644
--- a/be/test/io/cache/cached_remote_file_reader_test.cpp
+++ b/be/test/io/cache/cached_remote_file_reader_test.cpp
@@ -22,6 +22,7 @@
 #include "cloud/config.h"
 #include "io/cache/async_cache_write_manager_metrics.h"
 #include "io/cache/inflight_write_buffer_index.h"
+#include "io/file_factory.h"
 #include "util/time.h"
 
 namespace doris::io {
@@ -228,6 +229,65 @@ TEST_F(AsyncCachedRemoteFileReaderTest, 
external_reader_normalizes_tablet_id_for
     }
 }
 
+TEST_F(AsyncCachedRemoteFileReaderTest, 
external_cache_key_comes_from_the_key_function) {
+    create_cache("cached_external_reader_key_function");
+    constexpr int64_t mtime = 1700000000000;
+    auto external_reader =
+            [this](std::function<std::string(const std::string&, int64_t)> 
cache_key_function) {
+                FileReaderOptions options;
+                options.cache_type = FileCachePolicy::FILE_BLOCK_CACHE;
+                options.mtime = mtime;
+                options.cache_key_function = std::move(cache_key_function);
+                return 
std::make_shared<CachedRemoteFileReader>(open_remote_file(), options);
+            };
+
+    // The reader keys the file by what the key function makes of its path and 
modification time.
+    auto keyed = external_reader([](const std::string& path, int64_t 
file_mtime) {
+        return fmt::format("http://storage-a:9000:{}:{}";, path, file_mtime);
+    });
+    EXPECT_EQ(keyed->_cache_hash, 
BlockFileCache::hash(fmt::format("http://storage-a:9000:{}:{}";,
+                                                                   
keyed->path().native(), mtime)));
+
+    // Without a key function the key is path:mtime.
+    auto unkeyed = external_reader(nullptr);
+    EXPECT_EQ(unkeyed->_cache_hash,
+              BlockFileCache::hash(fmt::format("{}:{}", 
unkeyed->path().native(), mtime)));
+}
+
+TEST_F(AsyncCachedRemoteFileReaderTest, 
file_factory_keys_s3_files_by_endpoint) {
+    create_cache("file_factory_s3_endpoint_key");
+    constexpr int64_t mtime = 1700000000000;
+    auto s3_reader = [](const std::string& endpoint) -> 
std::shared_ptr<CachedRemoteFileReader> {
+        FileSystemProperties properties;
+        properties.system_type = TFileType::FILE_S3;
+        properties.properties = {{"AWS_ENDPOINT", endpoint},
+                                 {"AWS_REGION", "us-east-1"},
+                                 {"AWS_ACCESS_KEY", "ak"},
+                                 {"AWS_SECRET_KEY", "sk"}};
+        FileDescription file_description;
+        file_description.path = "s3://bucket/data/file.parquet";
+        // A known size skips the HEAD request, so no object storage is needed.
+        file_description.file_size = 128;
+        file_description.mtime = mtime;
+        FileReaderOptions options;
+        options.cache_type = FileCachePolicy::FILE_BLOCK_CACHE;
+        options.mtime = mtime;
+        auto reader = FileFactory::create_file_reader(properties, 
file_description, options);
+        EXPECT_TRUE(reader.has_value()) << reader.error();
+        return 
std::dynamic_pointer_cast<CachedRemoteFileReader>(reader.value());
+    };
+
+    // Two endpoints serving the same bucket, key and modification time do not 
share cache blocks.
+    auto endpoint_a = s3_reader("http://127.0.0.1:9000";);
+    auto endpoint_b = s3_reader("http://127.0.0.2:9000";);
+    ASSERT_NE(endpoint_a, nullptr);
+    ASSERT_NE(endpoint_b, nullptr);
+    EXPECT_NE(endpoint_a->_cache_hash, endpoint_b->_cache_hash);
+    EXPECT_EQ(endpoint_a->_cache_hash,
+              BlockFileCache::hash(fmt::format(
+                      
"http://127.0.0.1:9000:s3://bucket/data/file.parquet:{}";, mtime)));
+}
+
 TEST_F(AsyncCachedRemoteFileReaderTest, 
preallocated_cache_block_can_cover_the_short_file_tail) {
     create_cache("cached_remote_reader_async_preallocated_file_tail");
     auto counting_reader = 
std::make_shared<CountingFileReader>(open_remote_file());
diff --git a/be/test/io/fs/http_file_reader_test.cpp 
b/be/test/io/fs/http_file_reader_test.cpp
index 1c63ce3fd21..d55ff5fa888 100644
--- a/be/test/io/fs/http_file_reader_test.cpp
+++ b/be/test/io/fs/http_file_reader_test.cpp
@@ -97,6 +97,25 @@ TEST(HttpFileReaderTest, ChunkResponseDisablesFileCache) {
     EXPECT_NE(std::dynamic_pointer_cast<HttpFileReader>(reader.value()), 
nullptr);
 }
 
+TEST(HttpFileReaderTest, PlainResponseIsNotFileCachedEither) {
+    // The file cache keys an external file by path and modification time. An 
HTTP source has no
+    // modification time, so a URL whose content changes must not be served 
from blocks cached
+    // from its old content.
+    FileSystemProperties properties;
+    properties.system_type = TFileType::FILE_HTTP;
+    // A known size skips the HEAD request, so no server is needed.
+    properties.properties = {{"file_size", "4"}};
+    FileDescription file_description;
+    file_description.path = "http://127.0.0.1/data.csv";;
+    FileReaderOptions opts;
+    opts.cache_type = FileCachePolicy::FILE_BLOCK_CACHE;
+
+    auto reader = FileFactory::create_file_reader(properties, 
file_description, opts);
+
+    ASSERT_TRUE(reader.has_value()) << reader.error();
+    EXPECT_NE(std::dynamic_pointer_cast<HttpFileReader>(reader.value()), 
nullptr);
+}
+
 TEST(HttpFileReaderTest, RangeReadDoesNotCrossKnownEof) {
     const std::string content = 
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
     EofSensitiveRangeHandler handler(content);
diff --git a/regression-test/data/external_table_p0/tvf/test_http_tvf.out 
b/regression-test/data/external_table_p0/tvf/test_http_tvf.out
index 6cc959956bd..a097aafa9f3 100644
--- a/regression-test/data/external_table_p0/tvf/test_http_tvf.out
+++ b/regression-test/data/external_table_p0/tvf/test_http_tvf.out
@@ -159,6 +159,12 @@ m  map<text,text>  Yes     false   \N      NONE
 -- !sql16 --
 204
 
+-- !replaced_before --
+1      aaaa
+
+-- !replaced_after --
+2      bbbb
+
 -- !sql17 --
 c1     text    Yes     false   \N      NONE
 
diff --git a/regression-test/suites/external_table_p0/tvf/test_http_tvf.groovy 
b/regression-test/suites/external_table_p0/tvf/test_http_tvf.groovy
index f22eb702a8e..485d267e48a 100644
--- a/regression-test/suites/external_table_p0/tvf/test_http_tvf.groovy
+++ b/regression-test/suites/external_table_p0/tvf/test_http_tvf.groovy
@@ -27,6 +27,8 @@ import java.net.URLDecoder
 
 suite("test_http_tvf", "p0") {
     def dataRoot = 
Paths.get(context.config.dataPath).toAbsolutePath().normalize()
+    // Served under mutable/: files whose content a case replaces between two 
queries.
+    def mutableRoot = 
Files.createTempDirectory("test_http_tvf_mutable").toAbsolutePath().normalize()
     HttpServer httpServer = HttpServer.create(new InetSocketAddress("0.0.0.0", 
0), 0)
 
     def writeResponse = { HttpExchange exchange, int status, byte[] body ->
@@ -77,8 +79,13 @@ suite("test_http_tvf", "p0") {
 
         def requestPath = URLDecoder.decode(exchange.requestURI.path, "UTF-8")
         def relativePath = requestPath.startsWith("/") ? 
requestPath.substring(1) : requestPath
-        def filePath = dataRoot.resolve(relativePath).normalize()
-        if (!filePath.startsWith(dataRoot)) {
+        def root = dataRoot
+        if (relativePath.startsWith("mutable/")) {
+            root = mutableRoot
+            relativePath = relativePath.substring("mutable/".length())
+        }
+        def filePath = root.resolve(relativePath).normalize()
+        if (!filePath.startsWith(root)) {
             writeResponse(exchange, 403, "Forbidden".getBytes("UTF-8"))
             return
         }
@@ -321,6 +328,27 @@ suite("test_http_tvf", "p0") {
         ) order by id;
     """
 
+    // The same URL serving new content of the same length: an HTTP response 
has no modification
+    // time for the file cache key, so the second query must not read blocks 
cached by the first.
+    sql """ set enable_file_cache = true """
+    def mutableCsv = mutableRoot.resolve("replaced.csv")
+    Files.write(mutableCsv, "1,aaaa\n".getBytes("UTF-8"))
+    order_qt_replaced_before """
+        select * from http(
+            "uri" = "${httpUrl("mutable/replaced.csv")}",
+            "format" = "csv",
+            "column_separator" = ","
+        );
+    """
+    Files.write(mutableCsv, "2,bbbb\n".getBytes("UTF-8"))
+    order_qt_replaced_after """
+        select * from http(
+            "uri" = "${httpUrl("mutable/replaced.csv")}",
+            "format" = "csv",
+            "column_separator" = ","
+        );
+    """
+
     // hf
     def hfCountOneFile = sql """
         select count(*) from


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

Reply via email to