This is an automated email from the ASF dual-hosted git repository.
lxy-9602 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new 3a45772c perf(manifest): prune entries by row range and cache decoded
Arrow IPC (#411)
3a45772c is described below
commit 3a45772c37f3b022fcb9da2096ade662e3eeb296
Author: gripleaf <[email protected]>
AuthorDate: Sat Oct 10 17:21:23 2026 +0800
perf(manifest): prune entries by row range and cache decoded Arrow IPC
(#411)
---
docs/source/user_guide/manifest_cache.rst | 44 +-
include/paimon/cache/cache.h | 9 +
src/paimon/CMakeLists.txt | 1 +
src/paimon/common/io/cache/cache.cpp | 12 +-
src/paimon/common/io/cache/lru_cache.cpp | 2 +-
src/paimon/common/io/cache/lru_cache.h | 2 +-
src/paimon/common/io/cache/lru_cache_test.cpp | 34 +
src/paimon/core/io/data_file_meta.h | 4 +
src/paimon/core/io/data_file_meta_serializer.cpp | 12 +-
.../manifest/index_manifest_file_handler_test.cpp | 53 +-
src/paimon/core/manifest/manifest_entry.h | 3 +
.../core/manifest/manifest_entry_serializer.cpp | 4 +-
src/paimon/core/manifest/manifest_file.cpp | 59 ++
src/paimon/core/manifest/manifest_file.h | 10 +
src/paimon/core/manifest/manifest_file_test.cpp | 6 +-
src/paimon/core/manifest/manifest_list_test.cpp | 80 ++-
.../core/manifest/manifest_row_range_test.cpp | 705 +++++++++++++++++++++
.../operation/append_only_file_store_scan_test.cpp | 45 +-
src/paimon/core/operation/file_store_scan.cpp | 9 +
src/paimon/core/utils/objects_file.h | 181 ++++--
20 files changed, 1173 insertions(+), 102 deletions(-)
diff --git a/docs/source/user_guide/manifest_cache.rst
b/docs/source/user_guide/manifest_cache.rst
index e1c095a7..f6dd034a 100644
--- a/docs/source/user_guide/manifest_cache.rst
+++ b/docs/source/user_guide/manifest_cache.rst
@@ -21,18 +21,24 @@ Manifest Cache
Overview
--------
-Paimon C++ caches raw manifest file bytes at the ``ObjectsFile<T>::Read()``
-layer. The cache uses the public ``Cache`` abstraction and is enabled through
-``ScanContextBuilder::WithCache()``. The cache covers data manifests, manifest
-lists, and index manifests because they all read through ``ObjectsFile<T>``.
+Paimon C++ caches decoded, schema-aligned manifest batches as Arrow IPC streams
+in ``ObjectsFile<T>``. The cache uses the public ``Cache`` abstraction and is
+enabled through ``ScanContextBuilder::WithCache()``. It covers data manifests,
+manifest lists, and index manifests because they all read through
``ObjectsFile<T>``.
For repeated ``get``, ``scan``, or batch ``get/scan -f`` requests in the same
process, the same snapshot often reads the same manifest files repeatedly. On a
-cache hit, the read path skips remote filesystem ``open/read``, builds an
-in-memory input stream from cached bytes, and still runs the format reader,
-Arrow decoding, and object deserialization. This design primarily reduces
-remote IO latency and bandwidth while keeping cache weight aligned with the
-actual cached bytes.
+cache hit, the read path skips remote filesystem ``open/read`` and Avro/ORC
+source-format decoding. It still reads the IPC stream, deserializes objects,
+and applies scan filters. Bucket and row-range reads can select entries before
+constructing their file metadata. Ordinary, bucket, and row-range reads share
+the same complete, query-independent cache entry.
+
+A cold cache load decodes the complete manifest and serializes its aligned
+batches into IPC. The loading reader consumes the original batches without an
+IPC round trip. Cold bucket reads may therefore decode more entries than
+uncached selective reads. Without a cache, reads retain the existing
+source-format reader path.
Configuration
-------------
@@ -86,11 +92,17 @@ Example:
Passing ``nullptr`` or omitting ``ScanContextBuilder::WithCache()`` leaves
manifest caching disabled.
-Future Optimizations
---------------------
+Cache Implementation Responsibilities
+-------------------------------------
-- Add hit, miss, bypass, and eviction metrics to read trace or metrics.
-- Add single-flight loading for high-concurrency misses on the same manifest
- path.
-- Evaluate a decoded-records second-level cache, configurable as a
- CPU-vs-memory tradeoff.
+Embedding applications can implement hit/miss and eviction statistics in their
+``Cache`` implementation. Coordination of concurrent loads for the same key
+also belongs to that implementation; ``ObjectsFile<T>`` does not deduplicate
+concurrent cache misses.
+
+Cache implementations should use ``CacheValue::GetMemoryUsage()`` for admission
+and eviction accounting. For manifest IPC, this reports the retained Arrow
+buffer capacity, including unused space from growth. ``GetSegment().Size()``
+continues to describe the valid IPC byte length. The built-in ``LruCache`` uses
+the memory usage value; existing cache values without an explicit allocation
+size continue to charge their segment length.
diff --git a/include/paimon/cache/cache.h b/include/paimon/cache/cache.h
index b282da74..e05b555e 100644
--- a/include/paimon/cache/cache.h
+++ b/include/paimon/cache/cache.h
@@ -88,12 +88,20 @@ class PAIMON_EXPORT Cache {
class PAIMON_EXPORT CacheValue {
public:
+ /// Charge the segment's logical size when no separate allocation size is
supplied.
CacheValue(const MemorySegment& segment, CacheCallback callback);
+ /// @param memory_usage Retained allocation size in bytes, including
unused capacity.
+ /// The charge is at least the segment's logical size.
+ CacheValue(const MemorySegment& segment, CacheCallback callback, int64_t
memory_usage);
+
~CacheValue();
const MemorySegment& GetSegment() const;
+ /// Bytes to charge against the cache budget. May exceed
GetSegment().Size().
+ int64_t GetMemoryUsage() const;
+
void OnEvict(const std::shared_ptr<CacheKey>& key) const;
bool operator==(const CacheValue& other) const;
@@ -101,6 +109,7 @@ class PAIMON_EXPORT CacheValue {
private:
MemorySegment segment_;
CacheCallback callback_;
+ int64_t memory_usage_;
};
} // namespace paimon
diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt
index eb17538d..ca2630e4 100644
--- a/src/paimon/CMakeLists.txt
+++ b/src/paimon/CMakeLists.txt
@@ -834,6 +834,7 @@ if(PAIMON_BUILD_TESTS)
core/manifest/manifest_entry_serializer_test.cpp
core/manifest/manifest_file_meta_serializer_test.cpp
core/manifest/manifest_file_test.cpp
+ core/manifest/manifest_row_range_test.cpp
core/manifest/manifest_list_test.cpp
core/manifest/partition_entry_test.cpp
core/manifest/file_entry_test.cpp
diff --git a/src/paimon/common/io/cache/cache.cpp
b/src/paimon/common/io/cache/cache.cpp
index 37a26e91..c4c9e541 100644
--- a/src/paimon/common/io/cache/cache.cpp
+++ b/src/paimon/common/io/cache/cache.cpp
@@ -19,12 +19,18 @@
#include "paimon/cache/cache.h"
+#include <algorithm>
#include <utility>
namespace paimon {
CacheValue::CacheValue(const MemorySegment& segment, CacheCallback callback)
- : segment_(segment), callback_(std::move(callback)) {}
+ : CacheValue(segment, std::move(callback), segment.Size()) {}
+
+CacheValue::CacheValue(const MemorySegment& segment, CacheCallback callback,
int64_t memory_usage)
+ : segment_(segment),
+ callback_(std::move(callback)),
+ memory_usage_(std::max<int64_t>(segment.Size(), memory_usage)) {}
CacheValue::~CacheValue() = default;
@@ -32,6 +38,10 @@ const MemorySegment& CacheValue::GetSegment() const {
return segment_;
}
+int64_t CacheValue::GetMemoryUsage() const {
+ return memory_usage_;
+}
+
void CacheValue::OnEvict(const std::shared_ptr<CacheKey>& key) const {
if (callback_) {
callback_(key);
diff --git a/src/paimon/common/io/cache/lru_cache.cpp
b/src/paimon/common/io/cache/lru_cache.cpp
index fbfca83c..892ddb6f 100644
--- a/src/paimon/common/io/cache/lru_cache.cpp
+++ b/src/paimon/common/io/cache/lru_cache.cpp
@@ -26,7 +26,7 @@ LruCache::LruCache(int64_t max_weight)
.expire_after_access_ms = -1,
.weigh_func = [](const std::shared_ptr<CacheKey>& /*key*/,
const std::shared_ptr<CacheValue>& value) ->
int64_t {
- return value ? value->GetSegment().Size() : 0;
+ return value ? value->GetMemoryUsage() : 0;
},
.removal_callback =
[](const std::shared_ptr<CacheKey>& key, const
std::shared_ptr<CacheValue>& value,
diff --git a/src/paimon/common/io/cache/lru_cache.h
b/src/paimon/common/io/cache/lru_cache.h
index 745ca7f5..3bf2b10a 100644
--- a/src/paimon/common/io/cache/lru_cache.h
+++ b/src/paimon/common/io/cache/lru_cache.h
@@ -32,7 +32,7 @@ namespace paimon {
/// LRU Cache implementation with weight-based eviction for block cache.
///
/// Wraps GenericLruCache with CacheKey/CacheValue types. Capacity is measured
-/// in bytes (sum of MemorySegment sizes). When an entry is evicted, its
+/// in bytes (sum of CacheValue::GetMemoryUsage()). When an entry is evicted,
its
/// CacheCallback is invoked to notify the upper layer.
///
/// @note Thread-safe: all public methods are protected by the underlying
GenericLruCache lock.
diff --git a/src/paimon/common/io/cache/lru_cache_test.cpp
b/src/paimon/common/io/cache/lru_cache_test.cpp
index 29eb10fe..6a9cab5b 100644
--- a/src/paimon/common/io/cache/lru_cache_test.cpp
+++ b/src/paimon/common/io/cache/lru_cache_test.cpp
@@ -113,6 +113,40 @@ TEST_F(LruCacheTest, TestPutInsertAndUpdate) {
ASSERT_EQ(result->GetSegment().Get(0), 'B');
}
+TEST_F(LruCacheTest, TestRetainedMemoryAccounting) {
+ LruCache cache(192);
+ auto segment = MemorySegment::AllocateHeapMemory(64, pool_.get());
+ auto value = std::make_shared<CacheValue>(segment, CacheCallback(), 128);
+ ASSERT_EQ(64, value->GetSegment().Size());
+ ASSERT_EQ(128, value->GetMemoryUsage());
+ ASSERT_EQ(64, CacheValue(segment, CacheCallback()).GetMemoryUsage());
+ ASSERT_EQ(64, CacheValue(segment, CacheCallback(), 32).GetMemoryUsage());
+
+ std::vector<std::shared_ptr<CacheKey>> evicted;
+ auto first = std::make_shared<CacheValue>(
+ segment, [&](const std::shared_ptr<CacheKey>& key) {
evicted.push_back(key); }, 128);
+ auto first_key = MakeKey(0);
+ ASSERT_OK(cache.Put(first_key, first));
+ ASSERT_EQ(128, cache.GetCurrentWeight());
+ ASSERT_OK(cache.Put(MakeKey(1), value));
+ ASSERT_EQ(1, cache.Size());
+ ASSERT_EQ(128, cache.GetCurrentWeight());
+ ASSERT_EQ(std::vector<std::shared_ptr<CacheKey>>{first_key}, evicted);
+ cache.InvalidateAll();
+ ASSERT_EQ(0, cache.GetCurrentWeight());
+
+ // The payload fits, but its retained allocation exceeds the cache budget.
+ LruCache small_cache(100);
+ ASSERT_OK_AND_ASSIGN(
+ std::shared_ptr<CacheValue> loaded,
+ small_cache.Get(MakeKey(0),
+ [&](const std::shared_ptr<CacheKey>&)
+ -> Result<std::shared_ptr<CacheValue>> { return
value; }));
+ ASSERT_EQ(value, loaded);
+ ASSERT_EQ(0, small_cache.Size());
+ ASSERT_EQ(0, small_cache.GetCurrentWeight());
+}
+
/// Verifies weight-based eviction: when total weight exceeds max, LRU entries
are evicted.
TEST_F(LruCacheTest, TestWeightBasedEviction) {
// Cache can hold at most 200 bytes
diff --git a/src/paimon/core/io/data_file_meta.h
b/src/paimon/core/io/data_file_meta.h
index 436d7378..ee3d4a9f 100644
--- a/src/paimon/core/io/data_file_meta.h
+++ b/src/paimon/core/io/data_file_meta.h
@@ -43,6 +43,10 @@ class Bytes;
/// Metadata of a data file.
struct DataFileMeta {
+ /// Field positions in DataType(), shared by serialization and manifest
pruning.
+ static constexpr int32_t kRowCountFieldIndex = 2;
+ static constexpr int32_t kFirstRowIdFieldIndex = 18;
+
static const BinaryRow& EmptyMinKey();
static const BinaryRow& EmptyMaxKey();
static constexpr int32_t DUMMY_LEVEL = 0;
diff --git a/src/paimon/core/io/data_file_meta_serializer.cpp
b/src/paimon/core/io/data_file_meta_serializer.cpp
index 9f88ebec..8fa7eba0 100644
--- a/src/paimon/core/io/data_file_meta_serializer.cpp
+++ b/src/paimon/core/io/data_file_meta_serializer.cpp
@@ -46,7 +46,7 @@ Result<BinaryRow> DataFileMetaSerializer::ToRow(const
std::shared_ptr<DataFileMe
BinaryRowWriter writer(&row, 32 * 1024, pool_.get());
writer.WriteString(0, BinaryString::FromString(meta->file_name,
pool_.get()));
writer.WriteLong(1, meta->file_size);
- writer.WriteLong(2, meta->row_count);
+ writer.WriteLong(DataFileMeta::kRowCountFieldIndex, meta->row_count);
auto min_key_bytes = SerializationUtils::SerializeBinaryRow(meta->min_key,
pool_.get());
writer.WriteBinary(3, *min_key_bytes);
auto max_key_bytes = SerializationUtils::SerializeBinaryRow(meta->max_key,
pool_.get());
@@ -86,9 +86,9 @@ Result<BinaryRow> DataFileMetaSerializer::ToRow(const
std::shared_ptr<DataFileMe
writer.WriteString(17,
BinaryString::FromString(meta->external_path.value(), pool_.get()));
}
if (meta->first_row_id == std::nullopt) {
- writer.SetNullAt(18);
+ writer.SetNullAt(DataFileMeta::kFirstRowIdFieldIndex);
} else {
- writer.WriteLong(18, meta->first_row_id.value());
+ writer.WriteLong(DataFileMeta::kFirstRowIdFieldIndex,
meta->first_row_id.value());
}
if (meta->write_cols == std::nullopt) {
writer.SetNullAt(19);
@@ -110,7 +110,7 @@ Result<std::shared_ptr<DataFileMeta>>
DataFileMetaSerializer::FromRow(
const InternalRow& row) const {
auto file_name = row.GetString(0);
auto file_size = row.GetLong(1);
- auto row_count = row.GetLong(2);
+ auto row_count = row.GetLong(DataFileMeta::kRowCountFieldIndex);
auto min_key = row.GetBinary(3);
auto max_key = row.GetBinary(4);
auto key_stats_row = row.GetRow(5, 3);
@@ -155,8 +155,8 @@ Result<std::shared_ptr<DataFileMeta>>
DataFileMetaSerializer::FromRow(
external_path = row.GetString(17).ToString();
}
std::optional<int64_t> first_row_id;
- if (!row.IsNullAt(18)) {
- first_row_id = row.GetLong(18);
+ if (!row.IsNullAt(DataFileMeta::kFirstRowIdFieldIndex)) {
+ first_row_id = row.GetLong(DataFileMeta::kFirstRowIdFieldIndex);
}
std::optional<std::vector<std::string>> write_cols;
diff --git a/src/paimon/core/manifest/index_manifest_file_handler_test.cpp
b/src/paimon/core/manifest/index_manifest_file_handler_test.cpp
index 42048dc7..e0ce213a 100644
--- a/src/paimon/core/manifest/index_manifest_file_handler_test.cpp
+++ b/src/paimon/core/manifest/index_manifest_file_handler_test.cpp
@@ -25,7 +25,10 @@
#include <vector>
#include "arrow/api.h"
+#include "arrow/io/memory.h"
+#include "arrow/ipc/api.h"
#include "gtest/gtest.h"
+#include "paimon/common/utils/path_util.h"
#include "paimon/core/core_options.h"
#include "paimon/core/deletionvectors/deletion_vectors_index_file.h"
#include "paimon/core/index/index_file_meta.h"
@@ -37,6 +40,7 @@
#include "paimon/format/file_format_factory.h"
#include "paimon/memory/memory_pool.h"
#include "paimon/testing/utils/binary_row_generator.h"
+#include "paimon/testing/utils/counting_cache_test_utils.h"
#include "paimon/testing/utils/testharness.h"
namespace paimon::test {
@@ -50,7 +54,8 @@ class IndexManifestFileHandlerTest : public testing::Test {
}
Result<std::unique_ptr<IndexManifestFile>> CreateManifestFile(
- int32_t bucket_mode, const std::string& file_format_identifier =
"avro") const {
+ int32_t bucket_mode, const std::string& file_format_identifier =
"avro",
+ const std::shared_ptr<Cache>& cache = nullptr) const {
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<FileFormat> file_format,
FileFormatFactory::Get(file_format_identifier,
{}));
auto schema = arrow::schema({arrow::field("f0", arrow::int32())});
@@ -63,6 +68,7 @@ class IndexManifestFileHandlerTest : public testing::Test {
/*global_index_external_path=*/std::nullopt,
/*index_file_in_data_file_dir=*/false, pool_));
PAIMON_ASSIGN_OR_RAISE(CoreOptions options, CoreOptions::FromMap({}));
+ options.WithCache(cache);
return IndexManifestFile::Create(dir_->GetFileSystem(), file_format,
"zstd", path_factory,
bucket_mode, pool_, options);
}
@@ -100,6 +106,51 @@ class IndexManifestFileHandlerTest : public testing::Test {
std::unique_ptr<UniqueTestDirectory> dir_;
};
+TEST_F(IndexManifestFileHandlerTest, DecodedCacheReusesIpcAcrossReaders) {
+ auto cache = std::make_shared<CountingRoutingCache>(CacheKind::MANIFEST,
1024 * 1024);
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<IndexManifestFile> writer,
+ CreateManifestFile(2, "avro", cache));
+ const std::vector<IndexManifestEntry> expected = {
+ MakeDvEntry(FileKind::Add(), BinaryRow::EmptyRow(), 0, "dv-0",
{"data-a", "data-b"}, 10),
+ MakeEntry(FileKind::Add(), BinaryRow::EmptyRow(), 1, "BTREE",
"index-1", 20)};
+ using WrittenFile = std::pair<std::string, int64_t>;
+ ASSERT_OK_AND_ASSIGN(WrittenFile written,
writer->WriteWithoutRolling(expected));
+ std::vector<IndexManifestEntry> cold;
+ ASSERT_OK(writer->Read(written.first, nullptr, written.second, &cold));
+ ASSERT_EQ(expected, cold);
+ auto key = CacheKey::ForKind(
+ PathUtil::JoinPath(FileStorePathFactory::ManifestPath(dir_->Str()),
written.first),
+ /*position=*/0, /*length=*/-1, CacheKind::MANIFEST);
+ ASSERT_OK_AND_ASSIGN(
+ std::shared_ptr<CacheValue> cached,
+ cache->Get(key,
+ [](const std::shared_ptr<CacheKey>&) ->
Result<std::shared_ptr<CacheValue>> {
+ return Status::Invalid("index manifest was not cached");
+ }));
+ ASSERT_TRUE(cached);
+ const auto& segment = cached->GetSegment();
+ auto buffer = std::make_shared<arrow::Buffer>(reinterpret_cast<const
uint8_t*>(segment.Data()),
+ segment.Size());
+ auto ipc = arrow::ipc::RecordBatchStreamReader::Open(
+ std::make_shared<arrow::io::BufferReader>(buffer));
+ ASSERT_TRUE(ipc.ok()) << ipc.status().ToString();
+ auto batch = ipc.ValueOrDie()->Next();
+ ASSERT_TRUE(batch.ok()) << batch.status().ToString();
+ ASSERT_TRUE(batch.ValueOrDie());
+ ASSERT_EQ(expected.size(), batch.ValueOrDie()->num_rows());
+ writer->DeleteQuietly(written.first);
+ writer.reset();
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<IndexManifestFile> reader,
+ CreateManifestFile(2, "avro", cache));
+ std::vector<IndexManifestEntry> warm;
+ ASSERT_OK(reader->Read(written.first, nullptr, written.second, &warm));
+ ASSERT_EQ(expected, warm);
+ ASSERT_EQ(1, cache->SupplierCallCount());
+ cache->Invalidate(key);
+ ASSERT_EQ(0, cache->Size());
+ ASSERT_NOK(reader->Read(written.first, nullptr, written.second, &warm));
+}
+
TEST_F(IndexManifestFileHandlerTest, GlobalCombinerDeletesThenAddsByFileName) {
ASSERT_OK_AND_ASSIGN(auto index_manifest_file,
CreateManifestFile(/*bucket_mode=*/4));
diff --git a/src/paimon/core/manifest/manifest_entry.h
b/src/paimon/core/manifest/manifest_entry.h
index ca6e8ffa..f7546113 100644
--- a/src/paimon/core/manifest/manifest_entry.h
+++ b/src/paimon/core/manifest/manifest_entry.h
@@ -38,6 +38,9 @@ namespace paimon {
/// Entry of a manifest file, representing an addition / deletion of a data
file.
class ManifestEntry : public FileEntry {
public:
+ /// Field position in DataType(), before the serializer prepends _VERSION.
+ static constexpr int32_t kFileFieldIndex = 4;
+
static const std::shared_ptr<arrow::DataType>& DataType();
static int64_t RecordCount(const std::vector<ManifestEntry>&
manifest_entries);
static std::optional<int64_t> NullableRecordCount(
diff --git a/src/paimon/core/manifest/manifest_entry_serializer.cpp
b/src/paimon/core/manifest/manifest_entry_serializer.cpp
index 2389cd8d..5e1a2be0 100644
--- a/src/paimon/core/manifest/manifest_entry_serializer.cpp
+++ b/src/paimon/core/manifest/manifest_entry_serializer.cpp
@@ -58,7 +58,7 @@ Result<ManifestEntry>
ManifestEntrySerializer::ConvertFrom(int32_t version,
SerializationUtils::DeserializeBinaryRow(partition_bytes));
auto bucket = row.GetInt(2);
auto total_buckets = row.GetInt(3);
- auto file = row.GetRow(4, data_file_meta_serializer_.NumFields());
+ auto file = row.GetRow(ManifestEntry::kFileFieldIndex,
data_file_meta_serializer_.NumFields());
if (!file) {
return Status::Invalid("ManifestEntry convert from row failed, with
null DataFileMeta");
}
@@ -80,7 +80,7 @@ Result<BinaryRow> ManifestEntrySerializer::ToRow(const
ManifestEntry& record) co
writer.WriteInt(4, record.TotalBuckets());
PAIMON_ASSIGN_OR_RAISE(BinaryRow data_file_meta_row,
data_file_meta_serializer_.ToRow(record.File()));
- writer.WriteRow(5, data_file_meta_row);
+ writer.WriteRow(ManifestEntry::kFileFieldIndex + 1, data_file_meta_row);
writer.Complete();
return row;
}
diff --git a/src/paimon/core/manifest/manifest_file.cpp
b/src/paimon/core/manifest/manifest_file.cpp
index d525f62a..c0a71a2f 100644
--- a/src/paimon/core/manifest/manifest_file.cpp
+++ b/src/paimon/core/manifest/manifest_file.cpp
@@ -44,6 +44,7 @@
#include "paimon/format/writer_builder.h"
#include "paimon/predicate/predicate_builder.h"
#include "paimon/status.h"
+#include "paimon/utils/row_range_index.h"
namespace arrow {
class DataType;
@@ -133,6 +134,64 @@ Status ManifestFile::ReadBucketEntries(const std::string&
file_name, int32_t buc
});
}
+Status ManifestFile::ReadRowRangeEntries(
+ const std::string& file_name, const RowRangeIndex& row_ranges,
+ const std::function<Result<bool>(const ManifestEntry&)>& filter,
+ std::optional<int64_t> file_size, std::vector<ManifestEntry>* entries)
const {
+ return ReadArrowBatches(
+ file_name, file_size,
+ [this, &row_ranges, &filter,
+ entries](const std::shared_ptr<arrow::StructArray>& batch) -> Status {
+ // ManifestMetaReader has aligned both the entry and its nested
file schema. Probe
+ // the two range columns without allocating DataFileMeta, stats or
binary keys.
+ // The serialized entry has a leading _VERSION field.
+ const auto& file_column =
batch->field(ManifestEntry::kFileFieldIndex + 1);
+ if (file_column->type_id() != arrow::Type::STRUCT) {
+ return Status::Invalid("Manifest entry file metadata must be a
struct");
+ }
+ auto files = checked_pointer_cast<arrow::StructArray>(file_column);
+ const auto& count_column =
files->field(DataFileMeta::kRowCountFieldIndex);
+ const auto& first_column =
files->field(DataFileMeta::kFirstRowIdFieldIndex);
+ if (count_column->type_id() != arrow::Type::INT64 ||
+ first_column->type_id() != arrow::Type::INT64) {
+ return Status::Invalid("Manifest entry row range must contain
int64 fields");
+ }
+ auto counts =
checked_pointer_cast<arrow::Int64Array>(count_column);
+ auto first_ids =
checked_pointer_cast<arrow::Int64Array>(first_column);
+ ColumnarRow row(batch->fields(), pool_, /*row_id=*/0);
+ for (int64_t i = 0; i < batch->length(); ++i) {
+ row.SetRowId(i);
+ PAIMON_RETURN_NOT_OK(
+
ManifestEntrySerializer::ValidateVersion(row.GetInt(kVersionFieldIndex)));
+ if (files->IsNull(i)) {
+ return Status::Invalid(
+ "ManifestEntry convert from row failed, with null
DataFileMeta");
+ }
+ if (!first_ids->IsNull(i) && !counts->IsNull(i)) {
+ const int64_t first = first_ids->Value(i);
+ const int64_t count = counts->Value(i);
+ // Missing or invalid ranges cannot prove an entry is
irrelevant. In
+ // particular, do not overflow when computing the
inclusive upper bound.
+ if (first >= 0 && count > 0 &&
+ first <= std::numeric_limits<int64_t>::max() - (count
- 1) &&
+ !row_ranges.Intersects(first, first + (count - 1))) {
+ continue;
+ }
+ }
+ PAIMON_ASSIGN_OR_RAISE(ManifestEntry entry,
serializer_->FromRow(row));
+ if (filter) {
+ PAIMON_ASSIGN_OR_RAISE(bool keep, filter(entry));
+ if (!keep) {
+ continue;
+ }
+ }
+ entries->push_back(std::move(entry));
+ }
+ return Status::OK();
+ },
+ /*prepare_reader=*/nullptr);
+}
+
Status ManifestFile::PrepareBucketRead(int32_t bucket,
const std::optional<int32_t>&
expected_total_buckets,
std::unique_ptr<FileBatchReader>*
reader) const {
diff --git a/src/paimon/core/manifest/manifest_file.h
b/src/paimon/core/manifest/manifest_file.h
index 3840368f..c37882b5 100644
--- a/src/paimon/core/manifest/manifest_file.h
+++ b/src/paimon/core/manifest/manifest_file.h
@@ -19,6 +19,7 @@
#pragma once
#include <cstdint>
+#include <functional>
#include <memory>
#include <optional>
#include <string>
@@ -46,6 +47,7 @@ class WriterBuilder;
class PathFactory;
class ManifestFileMeta;
class ManifestEntry;
+class RowRangeIndex;
class MemoryPool;
/// This file includes several `ManifestEntry`s, representing the additional
changes since last
@@ -76,6 +78,14 @@ class ManifestFile : public ObjectsFile<ManifestEntry> {
std::optional<int64_t> file_size,
std::vector<ManifestEntry>* entries) const;
+ /// Read entries intersecting row ID ranges before constructing their file
metadata.
+ /// Unknown row ranges are retained. Add and Delete entries use the same
selection, and
+ /// the ordinary entry filter still runs on every retained entry.
+ Status ReadRowRangeEntries(const std::string& file_name, const
RowRangeIndex& row_ranges,
+ const std::function<Result<bool>(const
ManifestEntry&)>& filter,
+ std::optional<int64_t> file_size,
+ std::vector<ManifestEntry>* entries) const;
+
private:
Status PrepareBucketRead(int32_t bucket, const std::optional<int32_t>&
expected_total_buckets,
std::unique_ptr<FileBatchReader>* reader) const;
diff --git a/src/paimon/core/manifest/manifest_file_test.cpp
b/src/paimon/core/manifest/manifest_file_test.cpp
index df5bcbd8..b0ce4b9e 100644
--- a/src/paimon/core/manifest/manifest_file_test.cpp
+++ b/src/paimon/core/manifest/manifest_file_test.cpp
@@ -314,7 +314,7 @@ TEST_F(ManifestFileTest,
TestManifestCacheIsDisabledWithoutInjectedCache) {
ASSERT_EQ(0, counting_file_system->get_file_status_count);
}
-TEST_F(ManifestFileTest, TestManifestCacheReusesCachedBytes) {
+TEST_F(ManifestFileTest, TestManifestCacheReusesDecodedBatches) {
auto pool = GetDefaultPool();
auto counting_file_system = std::make_shared<CountingFileSystem>();
auto manifest_cache =
@@ -500,7 +500,7 @@ TEST_F(ManifestFileTest,
TestInferredBucketProbeSkipsArrowMaterialization) {
manifest_file->WriteWithoutRolling({excluded,
selected}));
std::vector<ManifestEntry> warm;
ASSERT_OK(manifest_file->Read(written.first, nullptr,
/*file_size=*/std::nullopt, &warm));
- // Cached bytes, if available, belong to a separate pool from reader
allocations.
+ // Warm decoded batches, if available, belong to a separate pool from
reader allocations.
for (bool inferred : {false, true}) {
std::shared_ptr<MemoryPool> read_pool = GetMemoryPool();
ASSERT_OK_AND_ASSIGN(
@@ -571,7 +571,7 @@ TEST_F(ManifestFileTest,
TestReadBucketEntriesSkipsDeserializingOtherBuckets) {
WrittenFile written_file,
manifest_file->WriteWithoutRolling({invalid_other_bucket,
valid_target_bucket}));
- // Exercise both a cold cache and reuse of the retained manifest bytes.
+ // Exercise both a cold cache and reuse of the decoded manifest
batches.
for (int32_t read = 0; read < 2; ++read) {
std::vector<ManifestEntry> bucket_entries;
ASSERT_OK(manifest_file->ReadBucketEntries(written_file.first,
/*bucket=*/0,
diff --git a/src/paimon/core/manifest/manifest_list_test.cpp
b/src/paimon/core/manifest/manifest_list_test.cpp
index d8d25538..fa839b3f 100644
--- a/src/paimon/core/manifest/manifest_list_test.cpp
+++ b/src/paimon/core/manifest/manifest_list_test.cpp
@@ -22,9 +22,11 @@
#include <optional>
#include <variant>
+#include "arrow/io/memory.h"
+#include "arrow/ipc/api.h"
#include "arrow/type.h"
#include "gtest/gtest.h"
-#include "paimon/core/core_options.h"
+#include "paimon/common/utils/path_util.h"
#include "paimon/core/manifest/manifest_file_meta.h"
#include "paimon/core/snapshot.h"
#include "paimon/core/stats/simple_stats.h"
@@ -34,6 +36,7 @@
#include "paimon/fs/local/local_file_system.h"
#include "paimon/memory/memory_pool.h"
#include "paimon/testing/utils/binary_row_generator.h"
+#include "paimon/testing/utils/counting_cache_test_utils.h"
#include "paimon/testing/utils/testharness.h"
namespace paimon::test {
@@ -97,7 +100,8 @@ class ManifestListTest : public testing::Test {
std::unique_ptr<ManifestList> CreateManifestList(
const std::shared_ptr<FileSystem>& file_system, const std::string&
file_format_str,
- const std::string& root_path, const std::shared_ptr<MemoryPool>& pool)
const {
+ const std::string& root_path, const std::shared_ptr<MemoryPool>& pool,
+ const std::shared_ptr<Cache>& cache = nullptr) const {
EXPECT_OK_AND_ASSIGN(std::shared_ptr<FileFormat> file_format,
FileFormatFactory::Get(file_format_str, {}));
auto unused_schema =
arrow::schema(arrow::FieldVector({arrow::field("f0", arrow::utf8())}));
@@ -109,10 +113,9 @@ class ManifestListTest : public testing::Test {
/*legacy_partition_name_enabled=*/true,
/*external_paths=*/{},
/*global_index_external_path=*/std::nullopt,
/*index_file_in_data_file_dir=*/false, pool));
- EXPECT_OK_AND_ASSIGN(CoreOptions options, CoreOptions::FromMap({}));
- EXPECT_OK_AND_ASSIGN(auto manifest_list,
- ManifestList::Create(file_system, file_format,
"zstd", path_factory,
- options.GetCache(), pool));
+ EXPECT_OK_AND_ASSIGN(
+ auto manifest_list,
+ ManifestList::Create(file_system, file_format, "zstd",
path_factory, cache, pool));
return manifest_list;
}
@@ -127,6 +130,71 @@ class ManifestListTest : public testing::Test {
}
};
+TEST_F(ManifestListTest, DecodedCacheReusesIpcAcrossReaders) {
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ auto pool = GetDefaultPool();
+ auto cache = std::make_shared<CountingRoutingCache>(CacheKind::MANIFEST,
1024 * 1024);
+ auto writer = CreateManifestList(dir->GetFileSystem(), "avro", dir->Str(),
pool, cache);
+ const std::vector<ManifestFileMeta> expected = {MakeMeta("manifest-a",
100, 2, 0),
+ MakeMeta("manifest-b",
200, 0, 1)};
+ using WrittenFile = std::pair<std::string, int64_t>;
+ ASSERT_OK_AND_ASSIGN(WrittenFile written, writer->Write(expected));
+ std::vector<ManifestFileMeta> cold;
+ ASSERT_OK(writer->Read(written.first, nullptr, written.second, &cold));
+ ASSERT_EQ(expected, cold);
+ auto key = CacheKey::ForKind(
+ PathUtil::JoinPath(FileStorePathFactory::ManifestPath(dir->Str()),
written.first),
+ /*position=*/0, /*length=*/-1, CacheKind::MANIFEST);
+ ASSERT_OK_AND_ASSIGN(
+ std::shared_ptr<CacheValue> cached,
+ cache->Get(key,
+ [](const std::shared_ptr<CacheKey>&) ->
Result<std::shared_ptr<CacheValue>> {
+ return Status::Invalid("manifest list was not cached");
+ }));
+ ASSERT_TRUE(cached);
+ const auto& segment = cached->GetSegment();
+ auto buffer = std::make_shared<arrow::Buffer>(reinterpret_cast<const
uint8_t*>(segment.Data()),
+ segment.Size());
+ // The existing cache key contains IPC, not source-format bytes.
+ auto ipc = arrow::ipc::RecordBatchStreamReader::Open(
+ std::make_shared<arrow::io::BufferReader>(buffer));
+ ASSERT_TRUE(ipc.ok()) << ipc.status().ToString();
+ auto batch = ipc.ValueOrDie()->Next();
+ ASSERT_TRUE(batch.ok()) << batch.status().ToString();
+ ASSERT_TRUE(batch.ValueOrDie());
+ ASSERT_EQ(expected.size(), batch.ValueOrDie()->num_rows());
+ writer->DeleteQuietly(written.first);
+ writer.reset();
+ auto reader = CreateManifestList(dir->GetFileSystem(), "avro", dir->Str(),
pool, cache);
+ std::vector<ManifestFileMeta> warm;
+ ASSERT_OK(reader->Read(written.first, nullptr, written.second, &warm));
+ ASSERT_EQ(expected, warm);
+ ASSERT_EQ(1, cache->SupplierCallCount());
+ cache->Invalidate(key);
+ ASSERT_EQ(0, cache->Size());
+ ASSERT_NOK(reader->Read(written.first, nullptr, written.second, &warm));
+}
+
+TEST_F(ManifestListTest, OrcDecodedCachePreservesLegacyMetadata) {
+ auto pool = GetDefaultPool();
+ auto fs = std::make_shared<LocalFileSystem>();
+ const std::string path = GetDataDir() + "/orc/append_09.db/append_09";
+ const std::string file =
"manifest-list-f2d59cb8-3ec6-4860-b34b-050b1a533416-2";
+ auto uncached = CreateManifestList(fs, "orc", path, pool);
+ std::vector<ManifestFileMeta> expected;
+ ASSERT_OK(uncached->Read(file, nullptr, std::nullopt, &expected));
+ ASSERT_EQ(4, expected.size());
+ auto cache = std::make_shared<CountingRoutingCache>(CacheKind::MANIFEST,
1024 * 1024);
+ for (int32_t attempt = 0; attempt < 2; ++attempt) {
+ auto reader = CreateManifestList(fs, "orc", path, pool, cache);
+ std::vector<ManifestFileMeta> actual;
+ ASSERT_OK(reader->Read(file, nullptr, std::nullopt, &actual));
+ ASSERT_EQ(expected, actual);
+ ASSERT_EQ(1, cache->SupplierCallCount());
+ }
+}
+
TEST_F(ManifestListTest, TestSimple) {
auto pool = GetDefaultPool();
auto manifest_file_metas =
diff --git a/src/paimon/core/manifest/manifest_row_range_test.cpp
b/src/paimon/core/manifest/manifest_row_range_test.cpp
new file mode 100644
index 00000000..a4f60fea
--- /dev/null
+++ b/src/paimon/core/manifest/manifest_row_range_test.cpp
@@ -0,0 +1,705 @@
+/*
+ * 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 <atomic>
+#include <future>
+#include <limits>
+#include <map>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "arrow/api.h"
+#include "arrow/c/bridge.h"
+#include "fmt/format.h"
+#include "gtest/gtest.h"
+#include "paimon/common/utils/checked_cast.h"
+#include "paimon/common/utils/path_util.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/io/meta_to_arrow_array_converter.h"
+#include "paimon/core/manifest/manifest_entry_serializer.h"
+#include "paimon/core/manifest/manifest_file.h"
+#include "paimon/core/manifest/manifest_list.h"
+#include "paimon/core/operation/data_evolution_file_store_scan.h"
+#include "paimon/core/operation/file_store_scan.h"
+#include "paimon/core/utils/file_store_path_factory.h"
+#include "paimon/format/file_format.h"
+#include "paimon/format/file_format_factory.h"
+#include "paimon/format/format_writer.h"
+#include "paimon/format/writer_builder.h"
+#include "paimon/fs/local/local_file_system.h"
+#include "paimon/testing/utils/counting_cache_test_utils.h"
+#include "paimon/testing/utils/testharness.h"
+#include "paimon/utils/row_range_index.h"
+
+namespace paimon::test {
+class RowRangeManifestFileTest : public ::testing::Test {
+ protected:
+ Result<std::unique_ptr<ManifestFile>> CreateManifest(
+ const std::string& path, const std::shared_ptr<FileSystem>& fs, bool
cache_enabled,
+ const std::shared_ptr<Cache>& cache = nullptr,
+ const std::shared_ptr<MemoryPool>& pool = GetDefaultPool()) {
+ PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<FileFormat> format,
+ FileFormatFactory::Get("avro", {}));
+ auto schema = arrow::schema(arrow::FieldVector{});
+ PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<FileStorePathFactory>
path_factory,
+ FileStorePathFactory::Create(path, schema, {},
"", "avro", "data-",
+ true, {},
std::nullopt, false, pool));
+ PAIMON_ASSIGN_OR_RAISE(CoreOptions options,
+
CoreOptions::FromMap({{Options::READ_BATCH_SIZE, "2"}}));
+ if (cache) {
+ options.WithCache(cache);
+ } else if (cache_enabled) {
+ options.WithCache(std::make_shared<LruCache>(64 * 1024 * 1024));
+ }
+
PAIMON_RETURN_NOT_OK(fs->Mkdirs(FileStorePathFactory::ManifestPath(path)));
+ return ManifestFile::Create(fs, format, "null", path_factory, 1024,
pool, options, schema);
+ }
+
+ Result<ManifestEntry> Entry(const std::string& name,
std::optional<int64_t> first,
+ int64_t count) {
+ PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<DataFileMeta> meta,
+ DataFileMeta::ForAppend(name, 100, count,
SimpleStats::EmptyStats(),
+ 0, 0, 0,
FileSource::Append(), std::nullopt,
+ std::nullopt, first,
std::nullopt));
+ return ManifestEntry(FileKind::Add(), BinaryRow::EmptyRow(), 0, 1,
meta);
+ }
+
+ Status WriteArray(const std::string& path, const std::string& name,
+ const std::shared_ptr<arrow::StructArray>& array) {
+ PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<FileFormat> format,
+ FileFormatFactory::Get("avro", {}));
+ ArrowSchema schema;
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportType(*array->type(),
&schema));
+ PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<WriterBuilder> builder,
+ format->CreateWriterBuilder(&schema, 2));
+ LocalFileSystem fs;
+ PAIMON_ASSIGN_OR_RAISE(
+ std::shared_ptr<OutputStream> output,
+ fs.Create(FileStorePathFactory::ManifestPath(path) + "/" + name,
false));
+ PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FormatWriter> writer,
+ builder->Build(output, "null"));
+ ArrowArray batch;
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*array, &batch));
+ PAIMON_RETURN_NOT_OK(writer->AddBatch(&batch));
+ PAIMON_RETURN_NOT_OK(writer->Flush());
+ PAIMON_RETURN_NOT_OK(writer->Finish());
+ return output->Close();
+ }
+};
+
+TEST_F(RowRangeManifestFileTest, ArrowCacheReuseEvictionAndConcurrentReaders) {
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ const auto cache_kind = CacheKind::MANIFEST;
+ auto cache = std::make_shared<CountingRoutingCache>(cache_kind, 1024 *
1024);
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> manifest,
+ CreateManifest(dir->Str(), dir->GetFileSystem(),
true, cache));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry a, Entry("a.parquet", 100, 10));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry b, Entry("b.parquet", 110, 10));
+ using WrittenFile = std::pair<std::string, int64_t>;
+ ASSERT_OK_AND_ASSIGN(WrittenFile written,
manifest->WriteWithoutRolling({a, b}));
+ ASSERT_OK_AND_ASSIGN(RowRangeIndex ranges,
RowRangeIndex::Create({Range(110, 110)}));
+ std::vector<ManifestEntry> expected{b};
+ auto read = [&]() -> Status {
+ PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ManifestFile> reader,
+ CreateManifest(dir->Str(),
dir->GetFileSystem(), true, cache));
+ std::vector<ManifestEntry> entries;
+ PAIMON_RETURN_NOT_OK(
+ reader->ReadRowRangeEntries(written.first, ranges, nullptr,
written.second, &entries));
+ if (entries != expected) {
+ return Status::Invalid("cached manifest result differs from
source");
+ }
+ return Status::OK();
+ };
+ ASSERT_OK(read());
+ // The first read loads the manifest once; concurrent warm reads reuse it.
+ ASSERT_EQ(1, cache->SupplierCallCount(cache_kind));
+ // Readers own their decode state; the cache only shares immutable bytes.
+ std::vector<std::future<Status>> readers;
+ for (int32_t i = 0; i < 8; ++i) {
+ readers.push_back(std::async(std::launch::async, read));
+ }
+ for (auto& reader : readers) {
+ ASSERT_OK(reader.get());
+ }
+ ASSERT_EQ(1, cache->SupplierCallCount(cache_kind));
+ cache->InvalidateAll();
+ ASSERT_OK(read());
+ ASSERT_EQ(2, cache->SupplierCallCount(cache_kind));
+ // A different immutable manifest must not reuse the previous file's
cached content.
+ ASSERT_OK_AND_ASSIGN(WrittenFile next, manifest->WriteWithoutRolling({a}));
+ std::vector<ManifestEntry> entries;
+ ASSERT_OK(manifest->ReadRowRangeEntries(next.first, ranges, nullptr,
next.second, &entries));
+ ASSERT_TRUE(entries.empty());
+ ASSERT_EQ(3, cache->SupplierCallCount(cache_kind));
+
+ auto unsupported_cache =
+ std::make_shared<CountingRoutingCache>(CacheKind::DATA_FILE_FOOTER,
1024 * 1024);
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> unsupported,
+ CreateManifest(dir->Str(), dir->GetFileSystem(),
true, unsupported_cache));
+ ASSERT_OK(
+ unsupported->ReadRowRangeEntries(written.first, ranges, nullptr,
written.second, &entries));
+ ASSERT_EQ(expected, entries);
+ entries.clear();
+ auto small_cache = std::make_shared<LruCache>(1);
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> uncached,
+ CreateManifest(dir->Str(), dir->GetFileSystem(),
true, small_cache));
+ ASSERT_OK(
+ uncached->ReadRowRangeEntries(written.first, ranges, nullptr,
written.second, &entries));
+ ASSERT_EQ(expected, entries);
+ ASSERT_LE(small_cache->GetCurrentWeight(), 1);
+ small_cache->InvalidateAll();
+ uncached.reset();
+ // Materialized entries must remain valid after both cache eviction and
reader destruction.
+ ASSERT_EQ(expected, entries);
+}
+
+TEST_F(RowRangeManifestFileTest,
ScanPlanPreservesResultsAcrossLazyDecodeAndCacheModes) {
+ auto pool = GetDefaultPool();
+ std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+ auto filters = std::make_shared<ScanFilter>(
+ nullptr, std::vector<std::map<std::string, std::string>>{},
std::nullopt);
+ auto schema = arrow::schema({arrow::field("value", arrow::int32())});
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<TableSchema> table_schema,
+ TableSchema::Create(0, schema, {}, {}, {}));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry a, Entry("a.parquet", 100, 10));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry b, Entry("b.parquet", 110, 10));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry c, Entry("c.parquet", 120, 10));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry unknown, Entry("unknown.parquet",
std::nullopt, 10));
+ ManifestEntry deleted(FileKind::Delete(), a.Partition(), a.Bucket(),
a.TotalBuckets(),
+ a.File());
+ struct Query {
+ std::optional<std::vector<Range>> ranges;
+ std::vector<ManifestEntry> expected;
+ int32_t retained_entries;
+ };
+ const std::vector<Query> queries = {
+ {std::vector<Range>{Range(100, 100)}, {unknown}, 3},
+ {std::vector<Range>{Range(110, 110)}, {b, unknown}, 2},
+ {std::vector<Range>{Range(120, 129)}, {unknown, c}, 2},
+ {std::vector<Range>{Range(109, 110), Range(129, 129)}, {b, unknown,
c}, 5},
+ {std::vector<Range>{Range(1000, 1000)}, {unknown}, 1},
+ {std::vector<Range>{}, {unknown}, 1},
+ {std::nullopt, {b, unknown, c}, 5}};
+ for (bool cache_enabled : {false, true}) {
+ SCOPED_TRACE(cache_enabled);
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ auto fs = dir->GetFileSystem();
+ auto cache = cache_enabled
+ ?
std::make_shared<CountingRoutingCache>(CacheKind::MANIFEST, 1024 * 1024)
+ : nullptr;
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<ManifestFile> manifest,
+ CreateManifest(dir->Str(), fs, cache_enabled,
cache));
+ using WrittenFile = std::pair<std::string, int64_t>;
+ ASSERT_OK_AND_ASSIGN(WrittenFile base,
manifest->WriteWithoutRolling({a, b, unknown}));
+ ASSERT_OK_AND_ASSIGN(WrittenFile delta,
manifest->WriteWithoutRolling({deleted, c}));
+ // Unknown manifest bounds force entry-level pruning even for disjoint
queries.
+ ManifestFileMeta base_meta(base.first, base.second, 3, 0,
SimpleStats::EmptyStats(), 0, 0,
+ 0, 0, 0, std::nullopt, std::nullopt);
+ ManifestFileMeta delta_meta(delta.first, delta.second, 1, 1,
SimpleStats::EmptyStats(), 0,
+ 0, 0, 0, 0, std::nullopt, std::nullopt);
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileFormat> format,
+ FileFormatFactory::Get("avro", {}));
+ ASSERT_OK_AND_ASSIGN(
+ std::shared_ptr<FileStorePathFactory> paths,
+ FileStorePathFactory::Create(dir->Str(), schema, {}, "", "avro",
"data-", true, {},
+ std::nullopt, false, pool));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<ManifestList> lists,
+ ManifestList::Create(fs, format, "null", paths,
cache, pool));
+ ASSERT_OK_AND_ASSIGN(WrittenFile base_list, lists->Write({base_meta}));
+ ASSERT_OK_AND_ASSIGN(WrittenFile delta_list,
lists->Write({delta_meta}));
+ Snapshot snapshot(1, 0, base_list.first, base_list.second,
delta_list.first,
+ delta_list.second, std::nullopt, std::nullopt,
std::nullopt, "test", 0,
+ Snapshot::CommitKind::Append(), 0, 30, 0,
std::nullopt, std::nullopt,
+ std::nullopt, std::nullopt, std::nullopt);
+ for (bool lazy_decode : {false, true}) {
+ SCOPED_TRACE(lazy_decode);
+ const int64_t previous_loads = cache ? cache->SupplierCallCount()
: 0;
+ if (cache) {
+ cache->InvalidateAll();
+ }
+ ASSERT_OK_AND_ASSIGN(
+ CoreOptions options,
+ CoreOptions::FromMap({{Options::DATA_EVOLUTION_ENABLED,
"true"},
+
{Options::SCAN_MANIFEST_ENTRY_LAZY_DECODE_ENABLED,
+ lazy_decode ? "true" : "false"}}));
+ options.WithCache(cache);
+ for (int32_t attempt = 0; attempt < 2; ++attempt) {
+ SCOPED_TRACE(attempt);
+ for (size_t i = 0; i < queries.size(); ++i) {
+ SCOPED_TRACE(i);
+ const auto& query = queries[i];
+
ASSERT_OK_AND_ASSIGN(std::unique_ptr<DataEvolutionFileStoreScan> scan,
+ DataEvolutionFileStoreScan::Create(
+ nullptr, nullptr, lists,
manifest, table_schema,
+ schema, filters, options,
executor, pool));
+ scan->WithSnapshot(snapshot);
+ if (query.ranges) {
+ ASSERT_OK_AND_ASSIGN(RowRangeIndex ranges,
+
RowRangeIndex::Create(query.ranges.value()));
+ scan->WithRowRangeIndex(ranges);
+ }
+ std::atomic<int32_t> filter_calls{0};
+ scan->WithLevelFilter([&](int32_t) {
+ ++filter_calls;
+ return true;
+ });
+
ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileStoreScan::RawPlan> plan,
+ scan->CreatePlan());
+ ASSERT_EQ(query.expected, plan->Files());
+ // The regular filter sees only retained entries when
early pruning is on.
+ // This also verifies that both the Add and Delete of a
reach the merge.
+ ASSERT_EQ(lazy_decode ? query.retained_entries : 5,
filter_calls.load());
+ if (cache) {
+ // Two manifest lists and two manifests load once per
cache reset.
+ ASSERT_EQ(previous_loads + 4,
cache->SupplierCallCount());
+ }
+ }
+ }
+ }
+ }
+}
+
+TEST_F(RowRangeManifestFileTest, OrcCachePreservesManifestMetadata) {
+ auto pool = GetDefaultPool();
+ auto fs = std::make_shared<LocalFileSystem>();
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileFormat> format,
FileFormatFactory::Get("orc", {}));
+ auto schema = arrow::schema(arrow::FieldVector{});
+ const std::string path = GetDataDir() + "/orc/append_09.db/append_09";
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<FileStorePathFactory> paths,
+ FileStorePathFactory::Create(path, schema, {}, "",
"orc", "data-", true,
+ {}, std::nullopt, false,
pool));
+ ASSERT_OK_AND_ASSIGN(CoreOptions options,
+ CoreOptions::FromMap({{Options::READ_BATCH_SIZE,
"2"}}));
+ ASSERT_OK_AND_ASSIGN(
+ std::unique_ptr<ManifestFile> uncached,
+ ManifestFile::Create(fs, format, "zstd", paths, 1024, pool, options,
schema));
+ const std::string name = "manifest-3ea5ee21-d399-4f1c-a749-2fc63dbf0852-1";
+ std::vector<ManifestEntry> expected;
+ ASSERT_OK(uncached->Read(name, nullptr, std::nullopt, &expected));
+ ASSERT_EQ(5, expected.size());
+ ASSERT_EQ(1721643142456LL,
expected[0].File()->creation_time.GetMillisecond());
+ // This legacy manifest has unknown row IDs, so every entry must be
retained.
+ ASSERT_OK_AND_ASSIGN(RowRangeIndex ranges, RowRangeIndex::Create({Range(0,
0)}));
+ std::vector<ManifestEntry> without_cache;
+ ASSERT_OK(uncached->ReadRowRangeEntries(name, ranges, nullptr,
std::nullopt, &without_cache));
+ ASSERT_EQ(expected, without_cache);
+
+ auto cache = std::make_shared<CountingRoutingCache>(CacheKind::MANIFEST,
1024 * 1024);
+ options.WithCache(cache);
+ for (int32_t attempt = 0; attempt < 2; ++attempt) {
+ SCOPED_TRACE(attempt);
+ ASSERT_OK_AND_ASSIGN(
+ std::unique_ptr<ManifestFile> reader,
+ ManifestFile::Create(fs, format, "zstd", paths, 1024, pool,
options, schema));
+ std::vector<ManifestEntry> actual;
+ ASSERT_OK(reader->ReadRowRangeEntries(name, ranges, nullptr,
std::nullopt, &actual));
+ ASSERT_EQ(expected, actual);
+ ASSERT_EQ(1, cache->SupplierCallCount(CacheKind::MANIFEST));
+ }
+}
+
+TEST_F(RowRangeManifestFileTest, EmptyManifestCacheReuse) {
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ auto cache = std::make_shared<CountingRoutingCache>(CacheKind::MANIFEST,
1024 * 1024);
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> manifest,
+ CreateManifest(dir->Str(), dir->GetFileSystem(),
true, cache));
+ using WrittenFile = std::pair<std::string, int64_t>;
+ ASSERT_OK_AND_ASSIGN(WrittenFile written,
manifest->WriteWithoutRolling({}));
+ ASSERT_OK_AND_ASSIGN(RowRangeIndex ranges, RowRangeIndex::Create({Range(0,
0)}));
+ for (int32_t attempt = 0; attempt < 2; ++attempt) {
+ std::vector<ManifestEntry> entries;
+ ASSERT_OK(manifest->ReadRowRangeEntries(written.first, ranges,
nullptr, written.second,
+ &entries));
+ ASSERT_TRUE(entries.empty());
+ ASSERT_EQ(1, cache->SupplierCallCount(CacheKind::MANIFEST));
+ }
+}
+
+TEST_F(RowRangeManifestFileTest, AllReadPathsShareOneDecodedEntry) {
+ for (int32_t first_reader : {0, 1, 2}) {
+ SCOPED_TRACE(first_reader);
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ auto cache =
std::make_shared<CountingRoutingCache>(CacheKind::MANIFEST, 1024 * 1024);
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> manifest,
+ CreateManifest(dir->Str(), dir->GetFileSystem(),
true, cache));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry a, Entry("a.parquet", 100, 10));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry b, Entry("b.parquet", 110, 10));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry c, Entry("c.parquet", 120, 10));
+ std::vector<ManifestEntry> source = {a, b, c};
+ using WrittenFile = std::pair<std::string, int64_t>;
+ ASSERT_OK_AND_ASSIGN(WrittenFile written,
manifest->WriteWithoutRolling(source));
+ ASSERT_OK_AND_ASSIGN(RowRangeIndex ranges,
RowRangeIndex::Create({Range(110, 110)}));
+ auto read = [&](int32_t mode) {
+ std::vector<ManifestEntry> actual;
+ if (mode == 0) {
+ ASSERT_OK(manifest->Read(written.first, nullptr,
written.second, &actual));
+ ASSERT_EQ(source, actual);
+ } else if (mode == 1) {
+ ASSERT_OK(manifest->ReadBucketEntries(written.first, 0,
std::nullopt,
+ written.second,
&actual));
+ ASSERT_EQ(source, actual);
+ } else {
+ ASSERT_OK(manifest->ReadRowRangeEntries(written.first, ranges,
nullptr,
+ written.second,
&actual));
+ ASSERT_EQ(std::vector<ManifestEntry>{b}, actual);
+ }
+ };
+ read(first_reader);
+ ASSERT_EQ(1, cache->Size());
+ ASSERT_EQ(1, cache->SupplierCallCount());
+ auto key = CacheKey::ForKind(
+ PathUtil::JoinPath(FileStorePathFactory::ManifestPath(dir->Str()),
written.first),
+ /*position=*/0, /*length=*/-1, CacheKind::MANIFEST);
+ ASSERT_OK_AND_ASSIGN(
+ std::shared_ptr<CacheValue> cached,
+ cache->Get(
+ key, [](const std::shared_ptr<CacheKey>&) ->
Result<std::shared_ptr<CacheValue>> {
+ return Status::Invalid("manifest is missing under the
existing cache key");
+ }));
+ ASSERT_TRUE(cached);
+ // All later paths must work entirely from the same decoded cache
entry.
+ manifest->DeleteQuietly(written.first);
+ for (int32_t mode : {0, 1, 2}) {
+ read(mode);
+ }
+ ASSERT_EQ(1, cache->Size());
+ ASSERT_EQ(1, cache->SupplierCallCount());
+ // Existing callers can still invalidate a manifest by its whole-file
key.
+ cache->Invalidate(key);
+ ASSERT_EQ(0, cache->Size());
+ }
+}
+
+TEST_F(RowRangeManifestFileTest,
ColdFilterFailureDoesNotPoisonCacheOrRepeatCallbacks) {
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ auto cache = std::make_shared<CountingRoutingCache>(CacheKind::MANIFEST,
1024 * 1024);
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> manifest,
+ CreateManifest(dir->Str(), dir->GetFileSystem(),
true, cache));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry a, Entry("a.parquet", 100, 10));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry b, Entry("b.parquet", 110, 10));
+ using WrittenFile = std::pair<std::string, int64_t>;
+ ASSERT_OK_AND_ASSIGN(WrittenFile written,
manifest->WriteWithoutRolling({a, b}));
+ int32_t calls = 0;
+ auto filter = [&](const ManifestEntry&) -> Result<bool> {
+ ++calls;
+ return Status::IOError("consumer failed");
+ };
+ std::vector<ManifestEntry> entries;
+ ASSERT_NOK_WITH_MSG(manifest->Read(written.first, filter, written.second,
&entries),
+ "consumer failed");
+ ASSERT_EQ(1, calls);
+ ASSERT_TRUE(entries.empty());
+ manifest->DeleteQuietly(written.first);
+ ASSERT_OK(manifest->Read(written.first, nullptr, written.second,
&entries));
+ ASSERT_EQ(std::vector<ManifestEntry>({a, b}), entries);
+ ASSERT_EQ(1, cache->SupplierCallCount());
+}
+
+TEST_F(RowRangeManifestFileTest, CachedBufferRetainsAllocatorUntilEviction) {
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ auto cache = std::make_shared<CountingRoutingCache>(CacheKind::MANIFEST,
1024 * 1024);
+ std::weak_ptr<MemoryPool> weak_pool;
+ ASSERT_OK_AND_ASSIGN(ManifestEntry entry, Entry("a.parquet", 100, 10));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry next, Entry("b.parquet", 110, 10));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry last, Entry("c.parquet", 120, 10));
+ const std::vector<ManifestEntry> expected = {entry, next, last};
+ using WrittenFile = std::pair<std::string, int64_t>;
+ WrittenFile written;
+ {
+ std::shared_ptr<MemoryPool> pool = GetMemoryPool();
+ weak_pool = pool;
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> manifest,
+ CreateManifest(dir->Str(), dir->GetFileSystem(),
true, cache, pool));
+ ASSERT_OK_AND_ASSIGN(written, manifest->WriteWithoutRolling(expected));
+ std::vector<ManifestEntry> entries;
+ ASSERT_OK(manifest->Read(written.first, nullptr, written.second,
&entries));
+ }
+ ASSERT_FALSE(weak_pool.expired());
+ std::vector<ManifestEntry> entries;
+ {
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> reader,
+ CreateManifest(dir->Str(), dir->GetFileSystem(),
true, cache));
+ reader->DeleteQuietly(written.first);
+ auto filter = [&](const ManifestEntry&) -> Result<bool> {
+ cache->InvalidateAll();
+ EXPECT_FALSE(weak_pool.expired());
+ return true;
+ };
+ // Eviction during the first batch must not invalidate later IPC
batches.
+ ASSERT_OK(reader->Read(written.first, filter, written.second,
&entries));
+ }
+ ASSERT_TRUE(weak_pool.expired());
+ ASSERT_EQ(expected, entries);
+}
+
+TEST_F(RowRangeManifestFileTest, CacheChargesRetainedArrowCapacity) {
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ auto fs = dir->GetFileSystem();
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> writer,
+ CreateManifest(dir->Str(), fs, false));
+ // Exercise growth beyond the initial IPC buffer allocation.
+ ASSERT_OK_AND_ASSIGN(ManifestEntry entry, Entry(std::string(5000, 'a') +
".parquet", 100, 10));
+ using WrittenFile = std::pair<std::string, int64_t>;
+ ASSERT_OK_AND_ASSIGN(WrittenFile written,
writer->WriteWithoutRolling({entry}));
+ std::shared_ptr<MemoryPool> pool = GetMemoryPool();
+ auto cache = std::make_shared<LruCache>(1024 * 1024);
+ {
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> reader,
+ CreateManifest(dir->Str(), fs, true, cache,
pool));
+ std::vector<ManifestEntry> entries;
+ ASSERT_OK(reader->Read(written.first, nullptr, written.second,
&entries));
+ ASSERT_EQ(std::vector<ManifestEntry>{entry}, entries);
+ }
+ // Only the cached IPC allocation remains in this reader's pool.
+ ASSERT_EQ(1, cache->Size());
+ ASSERT_EQ(pool->CurrentUsage(), cache->GetCurrentWeight());
+ auto key = CacheKey::ForKind(
+ PathUtil::JoinPath(FileStorePathFactory::ManifestPath(dir->Str()),
written.first),
+ /*position=*/0, /*length=*/-1, CacheKind::MANIFEST);
+ ASSERT_OK_AND_ASSIGN(
+ std::shared_ptr<CacheValue> cached,
+ cache->Get(key,
+ [](const std::shared_ptr<CacheKey>&) ->
Result<std::shared_ptr<CacheValue>> {
+ return Status::Invalid("manifest was not cached");
+ }));
+ ASSERT_TRUE(cached);
+ const int64_t logical_size = cached->GetSegment().Size();
+ ASSERT_GT(cached->GetMemoryUsage(), logical_size);
+ ASSERT_EQ(pool->CurrentUsage(), cached->GetMemoryUsage());
+ cached.reset();
+ cache->InvalidateAll();
+ ASSERT_EQ(0, pool->CurrentUsage());
+
+ // Admission must reject the allocation even though the valid IPC bytes
would fit.
+ auto small_cache = std::make_shared<LruCache>(logical_size);
+ {
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> reader,
+ CreateManifest(dir->Str(), fs, true, small_cache,
pool));
+ std::vector<ManifestEntry> entries;
+ ASSERT_OK(reader->Read(written.first, nullptr, written.second,
&entries));
+ ASSERT_EQ(std::vector<ManifestEntry>{entry}, entries);
+ ASSERT_EQ(0, small_cache->Size());
+ ASSERT_EQ(0, small_cache->GetCurrentWeight());
+ }
+ ASSERT_EQ(0, pool->CurrentUsage());
+}
+
+TEST_F(RowRangeManifestFileTest,
CacheAdmissionFailureReusesAlreadyDecodedBatches) {
+ class FailingCache : public CountingRoutingCache {
+ public:
+ FailingCache() : CountingRoutingCache(CacheKind::MANIFEST, 1024 *
1024) {}
+
+ Result<std::shared_ptr<CacheValue>> Get(
+ const std::shared_ptr<CacheKey>& key,
+ std::function<Result<std::shared_ptr<CacheValue>>(const
std::shared_ptr<CacheKey>&)>
+ supplier) override {
+ PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<CacheValue> value,
supplier(key));
+ after_load();
+ return Status::IOError("cache admission failed");
+ }
+
+ std::function<void()> after_load;
+ };
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ auto cache = std::make_shared<FailingCache>();
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> manifest,
+ CreateManifest(dir->Str(), dir->GetFileSystem(),
true, cache));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry entry, Entry("a.parquet", 100, 10));
+ using WrittenFile = std::pair<std::string, int64_t>;
+ ASSERT_OK_AND_ASSIGN(WrittenFile written,
manifest->WriteWithoutRolling({entry}));
+ // Reopening the source after an optional cache failure would now fail.
+ cache->after_load = [&]() { manifest->DeleteQuietly(written.first); };
+ int32_t calls = 0;
+ auto filter = [&](const ManifestEntry&) -> Result<bool> {
+ ++calls;
+ return true;
+ };
+ std::vector<ManifestEntry> entries;
+ ASSERT_OK(manifest->Read(written.first, filter, written.second, &entries));
+ ASSERT_EQ(1, calls);
+ ASSERT_EQ(std::vector<ManifestEntry>{entry}, entries);
+}
+
+TEST_F(RowRangeManifestFileTest, BoundariesUnknownRangesAndDeleteMerging) {
+ for (bool cache_enabled : {false, true}) {
+ SCOPED_TRACE(cache_enabled);
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> manifest,
+ CreateManifest(dir->Str(), dir->GetFileSystem(),
cache_enabled));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry a, Entry("a.parquet", 100, 10));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry b, Entry("b.parquet", 110, 10));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry unknown, Entry("unknown.parquet",
std::nullopt, 10));
+ ManifestEntry deleted(FileKind::Delete(), a.Partition(), a.Bucket(),
a.TotalBuckets(),
+ a.File());
+ std::vector<ManifestEntry> source = {a, b, deleted, unknown};
+ using WrittenFile = std::pair<std::string, int64_t>;
+ ASSERT_OK_AND_ASSIGN(WrittenFile written,
manifest->WriteWithoutRolling(source));
+ const std::vector<std::vector<Range>> queries = {{Range(100, 100)},
+ {Range(109, 109)},
+ {Range(110, 110)},
+ {Range(119, 119)},
+ {Range(99, 99)},
+ {Range(120, 120)},
+ {Range(109, 110)},
+ {Range(100, 100),
Range(119, 119)},
+ {}};
+ for (const auto& query : queries) {
+ ASSERT_OK_AND_ASSIGN(RowRangeIndex ranges,
RowRangeIndex::Create(query));
+ auto filter = [&ranges](const ManifestEntry& entry) ->
Result<bool> {
+ const auto& meta = entry.File();
+ return !meta->first_row_id ||
+ ranges.Intersects(*meta->first_row_id,
+ *meta->first_row_id + meta->row_count
- 1);
+ };
+ std::vector<ManifestEntry> ordinary;
+ ASSERT_OK(manifest->Read(written.first, filter, written.second,
&ordinary));
+ std::vector<ManifestEntry> selected;
+ ASSERT_OK(manifest->ReadRowRangeEntries(written.first, ranges,
filter, written.second,
+ &selected));
+ ASSERT_EQ(ordinary, selected);
+ std::vector<ManifestEntry> ordinary_live;
+ std::vector<ManifestEntry> selected_live;
+ ASSERT_OK(FileStoreScan::MergeLiveEntries(ordinary,
&ordinary_live));
+ ASSERT_OK(FileStoreScan::MergeLiveEntries(selected,
&selected_live));
+ ASSERT_EQ(ordinary_live, selected_live);
+ for (const auto& entry : selected_live) {
+ ASSERT_NE("a.parquet", entry.File()->file_name);
+ }
+ }
+ ASSERT_OK_AND_ASSIGN(RowRangeIndex ranges,
RowRangeIndex::Create({Range(100, 100)}));
+ std::vector<ManifestEntry> entries;
+ ASSERT_NOK_WITH_MSG(manifest->ReadRowRangeEntries(
+ written.first, ranges,
+ [](const ManifestEntry&) -> Result<bool> {
+ return Status::IOError("filter error");
+ },
+ written.second, &entries),
+ "filter error");
+ ASSERT_NOK(manifest->ReadRowRangeEntries("missing-manifest", ranges,
nullptr, std::nullopt,
+ &entries));
+ }
+}
+
+TEST_F(RowRangeManifestFileTest, UncertainAndOverflowingRangesAreRetained) {
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> manifest,
+ CreateManifest(dir->Str(), dir->GetFileSystem(),
false));
+ std::vector<ManifestEntry> source;
+ constexpr int64_t kMax = std::numeric_limits<int64_t>::max();
+ for (const auto& range : std::vector<std::pair<int64_t, int64_t>>{
+ {kMax, 2}, {kMax, 1}, {0, 0}, {-1, 10}, {0, -1}}) {
+ ASSERT_OK_AND_ASSIGN(ManifestEntry entry,
+ Entry(fmt::format("file-{}-{}.parquet",
range.first, range.second),
+ range.first, range.second));
+ source.push_back(entry);
+ }
+ using WrittenFile = std::pair<std::string, int64_t>;
+ ASSERT_OK_AND_ASSIGN(WrittenFile written,
manifest->WriteWithoutRolling(source));
+ ASSERT_OK_AND_ASSIGN(RowRangeIndex ranges, RowRangeIndex::Create({Range(0,
0)}));
+ std::vector<ManifestEntry> actual;
+ ASSERT_OK(
+ manifest->ReadRowRangeEntries(written.first, ranges, nullptr,
written.second, &actual));
+ source.erase(source.begin() + 1);
+ ASSERT_EQ(source, actual);
+}
+
+TEST_F(RowRangeManifestFileTest, SchemaEvolutionAndVersionValidation) {
+ constexpr int32_t kVersionedFileFieldIndex =
ManifestEntry::kFileFieldIndex + 1;
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ auto pool = GetDefaultPool();
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<ManifestFile> manifest,
+ CreateManifest(dir->Str(), dir->GetFileSystem(),
true));
+ ASSERT_OK_AND_ASSIGN(ManifestEntry entry, Entry("a.parquet", 100, 10));
+ ManifestEntrySerializer serializer(pool);
+ ASSERT_OK_AND_ASSIGN(BinaryRow row, serializer.ToRow(entry));
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<MetaToArrowArrayConverter> converter,
+
MetaToArrowArrayConverter::Create(serializer.GetDataType(), pool));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> array,
converter->NextBatch({row}));
+ auto batch = checked_pointer_cast<arrow::StructArray>(array);
+ ASSERT_OK_AND_ASSIGN(RowRangeIndex ranges,
RowRangeIndex::Create({Range(1000, 1000)}));
+ for (int32_t mode : {0, 1, 2, 3}) {
+ SCOPED_TRACE(mode);
+ auto fields = serializer.GetDataType()->fields();
+ auto columns = batch->fields();
+ auto file =
checked_pointer_cast<arrow::StructArray>(columns[kVersionedFileFieldIndex]);
+ auto file_fields =
checked_pointer_cast<arrow::StructType>(file->type())->fields();
+ auto file_columns = file->fields();
+ if (mode == 0) {
+ file_fields.erase(file_fields.begin() +
DataFileMeta::kFirstRowIdFieldIndex);
+ file_columns.erase(file_columns.begin() +
DataFileMeta::kFirstRowIdFieldIndex);
+ } else if (mode == 1) {
+ std::swap(file_fields[DataFileMeta::kRowCountFieldIndex],
+ file_fields[DataFileMeta::kFirstRowIdFieldIndex]);
+ std::swap(file_columns[DataFileMeta::kRowCountFieldIndex],
+ file_columns[DataFileMeta::kFirstRowIdFieldIndex]);
+ } else {
+ arrow::Int32Builder versions;
+ ASSERT_TRUE(versions.Append(mode == 2 ? 1 : 999).ok());
+ ASSERT_TRUE(versions.Finish(&columns[0]).ok());
+ }
+ auto updated_file = arrow::StructArray::Make(file_columns,
file_fields);
+ ASSERT_TRUE(updated_file.ok()) << updated_file.status().ToString();
+ columns[kVersionedFileFieldIndex] = updated_file.ValueOrDie();
+ fields[kVersionedFileFieldIndex] =
+
fields[kVersionedFileFieldIndex]->WithType(arrow::struct_(file_fields));
+ std::swap(fields[1], fields[kVersionedFileFieldIndex]);
+ std::swap(columns[1], columns[kVersionedFileFieldIndex]);
+ auto evolved_result = arrow::StructArray::Make(columns, fields);
+ ASSERT_TRUE(evolved_result.ok()) << evolved_result.status().ToString();
+ std::shared_ptr<arrow::StructArray> evolved =
evolved_result.ValueOrDie();
+ const std::string name = fmt::format("manifest-evolved-{}", mode);
+ ASSERT_OK(WriteArray(dir->Str(), name, evolved));
+ for (int32_t attempt = 0; attempt < 2; ++attempt) {
+ SCOPED_TRACE(attempt);
+ std::vector<ManifestEntry> actual;
+ if (mode >= 2) {
+ ASSERT_NOK_WITH_MSG(
+ manifest->ReadRowRangeEntries(name, ranges, nullptr,
std::nullopt, &actual),
+ (mode == 2 ? "not compatible" : "Unsupported version:
999"));
+ } else {
+ ASSERT_OK(
+ manifest->ReadRowRangeEntries(name, ranges, nullptr,
std::nullopt, &actual));
+ ASSERT_EQ(mode == 0 ? 1 : 0, actual.size());
+ if (mode == 0) {
+ ASSERT_FALSE(actual[0].File()->first_row_id.has_value());
+ }
+ }
+ if (attempt == 0) {
+ // The second read must preserve schema evolution and
validation from IPC alone.
+ manifest->DeleteQuietly(name);
+ }
+ }
+ }
+}
+
+} // namespace paimon::test
diff --git a/src/paimon/core/operation/append_only_file_store_scan_test.cpp
b/src/paimon/core/operation/append_only_file_store_scan_test.cpp
index 908f9bbb..1bb5bda0 100644
--- a/src/paimon/core/operation/append_only_file_store_scan_test.cpp
+++ b/src/paimon/core/operation/append_only_file_store_scan_test.cpp
@@ -235,7 +235,6 @@ TEST_F(AppendBucketPruningTest,
ReadsSingleBucketManifestOnce) {
{}, std::nullopt, false, pool_));
ASSERT_OK(fs->Mkdirs(FileStorePathFactory::ManifestPath(dir->Str())));
ASSERT_OK_AND_ASSIGN(CoreOptions options, CoreOptions::FromMap({}));
- options.WithCache(std::make_shared<LruCache>(16 * 1024 * 1024));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<ManifestFile> manifest,
ManifestFile::Create(fs, format, "null", paths, 1024
* 1024, pool_,
options, arrow::schema({})));
@@ -252,24 +251,34 @@ TEST_F(AppendBucketPruningTest,
ReadsSingleBucketManifestOnce) {
ASSERT_OK_AND_ASSIGN(BinaryRow row, serializer.ToRow(entries[0]));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> data,
converter->NextBatch({row}));
auto reader_builder =
std::make_shared<CountingManifestReaderBuilder>(data);
- manifest->reader_builder_ = reader_builder;
options_[Options::SCAN_MANIFEST_ENTRY_LAZY_DECODE_ENABLED] = "true";
- for (bool inferred : {false, true}) {
- SCOPED_TRACE(inferred);
- ASSERT_OK_AND_ASSIGN(
- std::unique_ptr<AppendOnlyFileStoreScan> scan,
- CreateScan(KeyEquals(), inferred ? std::nullopt :
std::optional<int32_t>(bucket)));
- scan->manifest_file_ = manifest;
- for (bool known_bounds : {false, true}) {
- SCOPED_TRACE(known_bounds);
- auto meta = metas[0];
- meta.min_bucket_ = known_bounds ? std::optional<int32_t>(bucket) :
std::nullopt;
- meta.max_bucket_ = meta.min_bucket_;
- reader_builder->rows_read = 0;
- std::vector<ManifestEntry> actual;
- ASSERT_OK(scan->ReadAndMergeBucketFileEntries({meta}, bucket,
&actual));
- ASSERT_EQ(actual, entries);
- ASSERT_EQ(reader_builder->rows_read.load(), known_bounds ? 1 : 2);
+ for (bool cache_enabled : {false, true}) {
+ SCOPED_TRACE(cache_enabled);
+ options.WithCache(cache_enabled ? std::make_shared<LruCache>(16 * 1024
* 1024) : nullptr);
+ ASSERT_OK_AND_ASSIGN(manifest, ManifestFile::Create(fs, format,
"null", paths, 1024 * 1024,
+ pool_, options,
arrow::schema({})));
+ manifest->reader_builder_ = reader_builder;
+ bool cache_warm = false;
+ for (bool inferred : {false, true}) {
+ SCOPED_TRACE(inferred);
+ ASSERT_OK_AND_ASSIGN(
+ std::unique_ptr<AppendOnlyFileStoreScan> scan,
+ CreateScan(KeyEquals(), inferred ? std::nullopt :
std::optional<int32_t>(bucket)));
+ scan->manifest_file_ = manifest;
+ for (bool known_bounds : {false, true}) {
+ SCOPED_TRACE(known_bounds);
+ auto meta = metas[0];
+ meta.min_bucket_ = known_bounds ?
std::optional<int32_t>(bucket) : std::nullopt;
+ meta.max_bucket_ = meta.min_bucket_;
+ reader_builder->rows_read = 0;
+ std::vector<ManifestEntry> actual;
+ ASSERT_OK(scan->ReadAndMergeBucketFileEntries({meta}, bucket,
&actual));
+ ASSERT_EQ(actual, entries);
+ const int64_t expected_rows =
+ cache_enabled ? (cache_warm ? 0 : 1) : (known_bounds ? 1 :
2);
+ ASSERT_EQ(reader_builder->rows_read.load(), expected_rows);
+ cache_warm = cache_enabled;
+ }
}
}
}
diff --git a/src/paimon/core/operation/file_store_scan.cpp
b/src/paimon/core/operation/file_store_scan.cpp
index fa22ac56..f1c9f786 100644
--- a/src/paimon/core/operation/file_store_scan.cpp
+++ b/src/paimon/core/operation/file_store_scan.cpp
@@ -613,6 +613,15 @@ bool FileStoreScan::FilterManifestByRowRanges(const
ManifestFileMeta& manifest)
Status FileStoreScan::ReadManifestFileMeta(const ManifestFileMeta& manifest,
std::vector<ManifestEntry>*
entries) const {
+ if (row_range_index_ && core_options_.DataEvolutionEnabled() &&
+ core_options_.ScanManifestEntryLazyDecodeEnabled()) {
+ return manifest_file_->ReadRowRangeEntries(
+ manifest.FileName(), row_range_index_.value(),
+ [this](const ManifestEntry& entry) -> Result<bool> {
+ return FilterManifestEntry(entry);
+ },
+ manifest.FileSize(), entries);
+ }
std::vector<ManifestEntry> unfiltered_entries;
PAIMON_RETURN_NOT_OK(manifest_file_->Read(
manifest.FileName(),
diff --git a/src/paimon/core/utils/objects_file.h
b/src/paimon/core/utils/objects_file.h
index e65ee75a..2a99e98d 100644
--- a/src/paimon/core/utils/objects_file.h
+++ b/src/paimon/core/utils/objects_file.h
@@ -19,6 +19,7 @@
#pragma once
#include <functional>
+#include <limits>
#include <memory>
#include <optional>
#include <string>
@@ -27,6 +28,8 @@
#include "arrow/c/bridge.h"
#include "arrow/c/helpers.h"
+#include "arrow/io/memory.h"
+#include "arrow/ipc/api.h"
#include "fmt/format.h"
#include "paimon/cache/cache.h"
#include "paimon/common/data/columnar/columnar_row.h"
@@ -44,8 +47,6 @@
#include "paimon/format/reader_builder.h"
#include "paimon/format/writer_builder.h"
#include "paimon/fs/file_system.h"
-#include "paimon/io/byte_array_input_stream.h"
-#include "paimon/memory/bytes.h"
#include "paimon/record_batch.h"
namespace paimon {
@@ -92,8 +93,7 @@ class ObjectsFile {
return Status::OK();
}
- // Optional preparation may wrap the reader and reread the file, using
either cached bytes
- // or the underlying file stream.
+ // Cached batches are query-independent. Optional preparation only applies
to uncached reads.
Status ReadArrowBatches(
const std::string& file_name, std::optional<int64_t> file_size,
const std::function<Status(const
std::shared_ptr<arrow::StructArray>&)>& consumer,
@@ -113,8 +113,15 @@ class ObjectsFile {
std::string compression_;
std::shared_ptr<Cache> cache_;
- Result<MemorySegment> ReadFileSegment(const std::string& file_path,
- const std::optional<int64_t>&
file_size) const;
+ Result<std::shared_ptr<CacheValue>> SerializeArrowBatches(
+ const std::string& file_name, std::optional<int64_t> file_size,
+ std::vector<std::shared_ptr<arrow::StructArray>>* batches,
+ std::optional<Status>* read_status) const;
+
+ Status ReadUncachedArrowBatches(
+ const std::string& file_name, std::optional<int64_t> file_size,
+ const std::function<Status(const
std::shared_ptr<arrow::StructArray>&)>& consumer,
+ const std::function<Status(std::unique_ptr<FileBatchReader>*)>&
prepare_reader) const;
/// Opens the file for reading, handing over the length when the caller
already has it.
Result<std::unique_ptr<InputStream>> OpenForRead(const std::string&
file_path,
@@ -183,37 +190,13 @@ Status ObjectsFile<T>::Read(const std::string& file_name,
}
template <typename T>
-Status ObjectsFile<T>::ReadArrowBatches(
+Status ObjectsFile<T>::ReadUncachedArrowBatches(
const std::string& file_name, std::optional<int64_t> file_size,
const std::function<Status(const std::shared_ptr<arrow::StructArray>&)>&
consumer,
const std::function<Status(std::unique_ptr<FileBatchReader>*)>&
prepare_reader) const {
std::string file_path = path_factory_->ToPath(file_name);
- std::shared_ptr<InputStream> file_input_stream;
- std::shared_ptr<Bytes> cached_bytes;
- if (cache_) {
- // Use a whole-file key so cache hits do not need a metadata lookup
just to discover file
- // length.
- auto cache_key =
- CacheKey::ForKind(file_path, /*position=*/0, /*length=*/-1,
CacheKind::MANIFEST);
- auto supplier =
- [this, &file_path,
- &file_size](const std::shared_ptr<CacheKey>&) ->
Result<std::shared_ptr<CacheValue>> {
- PAIMON_ASSIGN_OR_RAISE(MemorySegment segment,
ReadFileSegment(file_path, file_size));
- return std::make_shared<CacheValue>(segment, CacheCallback());
- };
- Result<std::shared_ptr<CacheValue>> cache_result =
cache_->Get(cache_key, supplier);
- if (cache_result.ok() && cache_result.value() &&
- cache_result.value()->GetSegment().Data() != nullptr) {
- cached_bytes =
cache_result.value()->GetSegment().GetOrCreateHeapMemory(pool_.get());
- file_input_stream =
- std::make_shared<ByteArrayInputStream>(cached_bytes->data(),
cached_bytes->size());
- }
- }
- if (!file_input_stream) {
- PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<InputStream>
unique_file_input_stream,
- OpenForRead(file_path, file_size));
- file_input_stream =
std::shared_ptr<InputStream>(std::move(unique_file_input_stream));
- }
+ PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> file_input_stream,
+ OpenForRead(file_path, file_size));
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FileBatchReader> batch_reader,
reader_builder_->Build(file_input_stream));
@@ -256,22 +239,126 @@ Result<std::unique_ptr<InputStream>>
ObjectsFile<T>::OpenForRead(
}
template <typename T>
-Result<MemorySegment> ObjectsFile<T>::ReadFileSegment(
- const std::string& file_path, const std::optional<int64_t>& file_size)
const {
- PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<InputStream> input_stream,
- OpenForRead(file_path, file_size));
- PAIMON_ASSIGN_OR_RAISE(int64_t input_length, input_stream->Length());
+Result<std::shared_ptr<CacheValue>> ObjectsFile<T>::SerializeArrowBatches(
+ const std::string& file_name, std::optional<int64_t> file_size,
+ std::vector<std::shared_ptr<arrow::StructArray>>* batches,
+ std::optional<Status>* read_status) const {
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
+ std::shared_ptr<arrow::io::BufferOutputStream> output,
+ arrow::io::BufferOutputStream::Create(4096, arrow_pool_.get()));
+ auto write_options = arrow::ipc::IpcWriteOptions::Defaults();
+ write_options.memory_pool = arrow_pool_.get();
+ write_options.use_threads = false;
+ std::shared_ptr<arrow::ipc::RecordBatchWriter> writer;
+ Status cache_status;
+ *read_status = ReadUncachedArrowBatches(
+ file_name, file_size,
+ [&](const std::shared_ptr<arrow::StructArray>& batch) -> Status {
+ // The loading caller consumes these original batches, without an
IPC round trip.
+ batches->push_back(batch);
+ if (!cache_status.ok()) {
+ return Status::OK();
+ }
+ auto write_batch = [&]() -> Status {
+ // Preserve physical types, including ORC nanosecond
timestamps.
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
+ std::shared_ptr<arrow::RecordBatch> record_batch,
+ arrow::RecordBatch::FromStructArray(batch,
arrow_pool_.get()));
+ if (!writer) {
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
+ writer, arrow::ipc::MakeStreamWriter(output,
record_batch->schema(),
+ write_options));
+ }
+
PAIMON_RETURN_NOT_OK_FROM_ARROW(writer->WriteRecordBatch(*record_batch));
+ return Status::OK();
+ };
+ // Optional serialization failures must not interrupt the source
read.
+ cache_status = write_batch();
+ return Status::OK();
+ },
+ /*prepare_reader=*/nullptr);
+ PAIMON_RETURN_NOT_OK(read_status->value());
+ PAIMON_RETURN_NOT_OK(cache_status);
+ if (!writer) {
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
+ writer,
+ arrow::ipc::MakeStreamWriter(
+ output, arrow::schema(serializer_->GetDataType()->fields()),
write_options));
+ }
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(writer->Close());
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Buffer> buffer,
output->Finish());
+ if (buffer->size() > std::numeric_limits<int32_t>::max()) {
+ return Status::Invalid("Manifest Arrow cache entry exceeds the memory
segment limit");
+ }
+ // Retain the IPC buffer and its allocator without copying the complete
stream into Bytes.
+ // The aliasing CacheValue keeps both alive, including after eviction with
active readers.
+ struct OwnedBuffer {
+ OwnedBuffer(const std::shared_ptr<arrow::MemoryPool>& owner,
+ const std::shared_ptr<arrow::Buffer>& data)
+ : pool(owner),
+ buffer(data),
+ value(MemorySegment::WrapView(reinterpret_cast<const
char*>(data->data()),
+
static_cast<int32_t>(data->size())),
+ CacheCallback(), data->capacity()) {}
+ std::shared_ptr<arrow::MemoryPool> pool;
+ std::shared_ptr<arrow::Buffer> buffer;
+ CacheValue value;
+ };
+ auto owner = std::make_shared<OwnedBuffer>(arrow_pool_, buffer);
+ auto* value = &owner->value;
+ return std::shared_ptr<CacheValue>(std::move(owner), value);
+}
- PAIMON_RETURN_NOT_OK(input_stream->Seek(0, FS_SEEK_SET));
- auto bytes = std::make_shared<Bytes>(input_length, pool_.get());
- PAIMON_ASSIGN_OR_RAISE(int64_t actual_read_size,
- input_stream->Read(bytes->data(), input_length));
- if (actual_read_size != input_length) {
- return Status::IOError(fmt::format(
- "Unexpected EOF while reading manifest file {}, expected {} bytes,
got {} bytes",
- file_path, input_length, actual_read_size));
+template <typename T>
+Status ObjectsFile<T>::ReadArrowBatches(
+ const std::string& file_name, std::optional<int64_t> file_size,
+ const std::function<Status(const std::shared_ptr<arrow::StructArray>&)>&
consumer,
+ const std::function<Status(std::unique_ptr<FileBatchReader>*)>&
prepare_reader) const {
+ if (!cache_) {
+ return ReadUncachedArrowBatches(file_name, file_size, consumer,
prepare_reader);
+ }
+ const std::string path = path_factory_->ToPath(file_name);
+ auto key = CacheKey::ForKind(path, /*position=*/0, /*length=*/-1,
CacheKind::MANIFEST);
+ std::optional<Status> read_status;
+ std::vector<std::shared_ptr<arrow::StructArray>> batches;
+ auto cached = cache_->Get(key, [&](const std::shared_ptr<CacheKey>&) {
+ return SerializeArrowBatches(file_name, file_size, &batches,
&read_status);
+ });
+ const bool usable = cached.ok() && cached.value() &&
cached.value()->GetSegment().Data();
+ if (read_status) {
+ // Publish the query-independent cache before invoking user filters. A
filter error
+ // does not invalidate the cache or affect other readers of this file.
+ PAIMON_RETURN_NOT_OK(read_status.value());
+ for (const auto& batch : batches) {
+ PAIMON_RETURN_NOT_OK(consumer(batch));
+ }
+ return Status::OK();
}
- return MemorySegment::Wrap(bytes);
+ if (!usable) {
+ return ReadUncachedArrowBatches(file_name, file_size, consumer,
prepare_reader);
+ }
+ const auto& segment = cached.value()->GetSegment();
+ auto buffer = std::make_shared<arrow::Buffer>(reinterpret_cast<const
uint8_t*>(segment.Data()),
+ segment.Size());
+ auto input = std::make_shared<arrow::io::BufferReader>(buffer);
+ auto read_options = arrow::ipc::IpcReadOptions::Defaults();
+ read_options.memory_pool = arrow_pool_.get();
+ read_options.use_threads = false;
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
+ std::shared_ptr<arrow::ipc::RecordBatchStreamReader> reader,
+ arrow::ipc::RecordBatchStreamReader::Open(input, read_options));
+ while (true) {
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::RecordBatch>
batch,
+ reader->Next());
+ if (!batch) {
+ break;
+ }
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::StructArray>
array,
+ batch->ToStructArray());
+ // Cached batches were already aligned by ManifestMetaReader before
serialization.
+ PAIMON_RETURN_NOT_OK(consumer(array));
+ }
+ return Status::OK();
}
template <typename T>