From b0bc568b93cd1c466675674576edfb835fcaaa78 Mon Sep 17 00:00:00 2001 From: Aaron Wu Date: Sun, 25 Oct 2026 00:30:49 +0800 Subject: [PATCH 01/11] feat(planning): enforce cumulative byte admission for retained snapshots --- docs/source/user_guide.rst | 1 + docs/source/user_guide/bounded_planning.rst | 151 +++ include/paimon/memory/planning_budget.h | 57 + include/paimon/table/source/table_scan.h | 8 + src/paimon/CMakeLists.txt | 39 + src/paimon/common/memory/planning_budget.cpp | 35 + .../common/memory/planning_budget_internal.h | 143 ++ .../core/table/source/bounded_table_scan.cpp | 1187 +++++++++++++++++ .../table/source/bounded_table_scan_test.cpp | 619 +++++++++ 9 files changed, 2240 insertions(+) create mode 100644 docs/source/user_guide/bounded_planning.rst create mode 100644 include/paimon/memory/planning_budget.h create mode 100644 src/paimon/common/memory/planning_budget.cpp create mode 100644 src/paimon/common/memory/planning_budget_internal.h create mode 100644 src/paimon/core/table/source/bounded_table_scan.cpp create mode 100644 src/paimon/core/table/source/bounded_table_scan_test.cpp diff --git a/docs/source/user_guide.rst b/docs/source/user_guide.rst index c03efd88a..ef71120a5 100644 --- a/docs/source/user_guide.rst +++ b/docs/source/user_guide.rst @@ -39,6 +39,7 @@ User Guide user_guide/commit user_guide/compaction user_guide/read + user_guide/bounded_planning user_guide/metrics user_guide/clean user_guide/prefetch diff --git a/docs/source/user_guide/bounded_planning.rst b/docs/source/user_guide/bounded_planning.rst new file mode 100644 index 000000000..b19838682 --- /dev/null +++ b/docs/source/user_guide/bounded_planning.rst @@ -0,0 +1,151 @@ +.. 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. + +Bounded metadata planning +========================= + +``TableScan::CreateWithPlanningBudget`` opts into synchronous, finite-byte metadata +planning. ``TableScan::Create`` retains its existing behavior. This API admits +allocations before requesting storage, including allocations outside ``MemoryPool``. +It does not plan first and compare a memory estimate afterwards. + +Budget contract +--------------- + +A ``PlanningBudget`` holds a cumulative requested-byte limit. Every admitted +allocation consumes bytes permanently; freeing storage does not replenish the +budget. Consequently temporary input, parser, decompression and output storage +cannot each reuse the whole limit. This conservative policy bounds their combined +peak and the total admitted allocation work; it can reject a query whose peak live +storage would fit. It measures requested storage, including allocator bookkeeping +added by this planner, rather than process RSS, malloc metadata, alignment padding, +stack storage or filesystem page cache. + +The budget covers retained caller-provided path/schema/options, scan and plan +objects, metadata file buffers, RapidJSON DOM and parser stacks, Avro schema lookup +and decoded records, Zstandard stream workspace and output, duplicate detection, +file metadata, split vectors, strings and shared ownership control blocks. +Zstandard frames with unknown decoded length grow output only after admission. +Compressed and decoded storage coexist within the same budget. + +Create the budget once per query. Scans may share it; atomic admission never exceeds +the shared limit. The first allocation failure makes the budget terminal. +Exhaustion returns ``CapacityError``; upstream allocation failure returns +``OutOfMemory``. Malformed inputs return an error and unsupported profiles return +``NotImplemented``. No failure returns a partial plan or invokes the unrestricted +planner. A failed scan returns its original failure on subsequent calls; a +successful batch scan produces one plan and then returns end of scan. + +Callers create and validate the ``ScanContext`` and retained schema before invoking +this API. Their construction, the fixed budget control object, fixed error Status +messages, and process-owned immutable empty row/statistics constants are outside +the variable planning payload. Existing shared executor, allocator and cache +services supplied through the context are also outside it; this planner schedules +no work on them and reads/writes no shared metadata cache. Creating or growing +those services is not covered. Data reading, metrics retrieval, split serialization +and user copies made after planning require their own allocation policy. + +Successful plans and separately retained nonempty splits retain the budget and its +upstream allocator through file-metadata shared owners. Destroying the scan or plan +therefore cannot invalidate an escaped split. Failed planning releases all temporary +storage, including JSON children whose parent allocation failed. Freed storage +still counts in ``BytesAdmitted()``. + +Admitted profile +---------------- + +The first implementation supports Linux with libstdc++ 9 or later and its C++11 string ABI. Other +platforms return ``NotImplemented``. It supports local filesystem tables with: + +* An explicitly retained schema and positive numeric ``scan.snapshot-id``. +* Unpartitioned append-only data, ``bucket=-1``, main branch and matching schema ID. +* Avro OCF manifest lists and version-2 ADD-only entries, null or Zstandard codec. +* Parquet or ORC data files without external paths, extra files, embedded indexes, + deletion vectors or data-evolution metadata. +* No streaming, planning predicates, partition/bucket filters, limit, realtime + context, catalog/format table or global-index result. + +DELETE entries, duplicate ADD identities, different file schemas, unsupported scan +selectors and unsupported codecs fail explicitly. Missing/expired snapshots fail. +Snapshot and schema IDs must match the bound request. Metadata names cannot escape +one filesystem component. Inputs must remain available and immutable throughout +planning, as with a retained snapshot read; this API does not pin snapshot retention +or provide an atomic filesystem snapshot. + +Planning emits one ordinary native split per file and omits pruning statistics. +The normal native data reader consumes these splits. This profile deliberately +avoids generic Avro/Arrow decoding, schema discovery, asynchronous planning and +shared caches, because those paths do not route all variable allocations through +an admission allocator. Enabling this opt-in control can change planning latency, +I/O and split grouping; it is not a performance-equivalent switch for the cached +unrestricted planner. It does not change normal scans. + +Allocation qualification +------------------------ + +Parser and decompressor buffers use a PMR resource backed by the supplied budget. +Shared metadata and plan blocks use ``allocate_shared`` with that allocator. +Ordinary SDK publication constructors still require their existing STL types. +Before invoking them the planner reserves GNU C++11 ABI string forward-range/copy +storage (at most length + 1), exact-size vector element storage, the split object +and its GNU shared-pointer control block. Small-string optimization may cause +conservative over-admission. No approximate per-row multiplier is used. + +This qualification relies on the GNU implementations of ``basic_string::_M_construct``, +``vector(size_type)`` and ``shared_ptr(Y*)``, not on a portable standard guarantee +about capacities. The Linux static test target links libstdc++ statically so that its out-of-line +string constructors are included in the ``operator new`` hook. A long-string +calibration proves observation and failure injection. The test observes upstream +pool requests during the complete public factory and planning call, and consumes +all retained-input admission before observing publication allocations so those +credits cannot mask a missing reservation. A new standard-library implementation or ABI needs requalification +before expanding this support gate. + +Usage +----- + +.. code-block:: cpp + + #include "paimon/memory/planning_budget.h" + #include "paimon/scan_context.h" + #include "paimon/table/source/table_scan.h" + + // schema_json is retained, validated table schema JSON bound by the caller. + auto pool = paimon::GetDefaultPool(); + PAIMON_ASSIGN_OR_RAISE(auto budget, + paimon::PlanningBudget::Create(32 * 1024 * 1024, pool)); + paimon::ScanContextBuilder builder(local_table_path); + builder.SetTableSchema(schema_json) + .WithMemoryPool(pool) + .AddOption("scan.snapshot-id", "1"); + PAIMON_ASSIGN_OR_RAISE(auto context, builder.Finish()); + PAIMON_ASSIGN_OR_RAISE(auto scan, + paimon::TableScan::CreateWithPlanningBudget( + std::move(context), budget)); + PAIMON_ASSIGN_OR_RAISE(auto plan, scan->CreatePlan()); + // Feed plan->Splits() to the normal TableRead. Data-reader bytes are separate. + +Tests +----- + +``paimon-bounded-table-scan-test`` exercises real Parquet full-field and ORC +``id``-projection plan/read parity (existing ORC TIME decoding is outside this change), +exact and insufficient quotas, shared concurrent admission, allocation failures at +every arena and publication allocation, escaped-split ownership, malformed metadata, +wrong writer field types, duplicate fields/files and unsupported profile rejection. +The GNU static target additionally observes actual allocation requests. The existing +core tests exercise the unchanged ordinary scan path. diff --git a/include/paimon/memory/planning_budget.h b/include/paimon/memory/planning_budget.h new file mode 100644 index 000000000..cc9dbc164 --- /dev/null +++ b/include/paimon/memory/planning_budget.h @@ -0,0 +1,57 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include + +#include "paimon/memory/memory_pool.h" +#include "paimon/result.h" +#include "paimon/visibility.h" + +namespace paimon { +class PlanningBudgetAccess; + +/// Query-owned cumulative allocation admission for bounded metadata planning. +/// Frees do not replenish admission: this deliberately also bounds temporary peaks. +/// The limit covers requested storage, not allocator overhead or process RSS. +class PAIMON_EXPORT PlanningBudget { + public: + static Result> Create( + uint64_t byte_limit, const std::shared_ptr& upstream = GetDefaultPool()); + + uint64_t ByteLimit() const { + return limit_; + } + uint64_t BytesAdmitted() const { + return admitted_.load(); + } + + private: + friend class PlanningBudgetAccess; + PlanningBudget(uint64_t limit, std::shared_ptr upstream); + const uint64_t limit_; + std::shared_ptr upstream_; + std::atomic admitted_{0}; + // The first allocation failure is terminal across scans sharing this budget. + std::atomic failure_{0}; +}; +} // namespace paimon diff --git a/include/paimon/table/source/table_scan.h b/include/paimon/table/source/table_scan.h index 8bae74c41..1cde8f221 100644 --- a/include/paimon/table/source/table_scan.h +++ b/include/paimon/table/source/table_scan.h @@ -32,6 +32,7 @@ namespace paimon { class ScanContext; +class PlanningBudget; /// A scanner interface for reading table's meta and create a plan. class PAIMON_EXPORT TableScan { @@ -42,6 +43,13 @@ class PAIMON_EXPORT TableScan { /// @return A Result containing a unique pointer to the `TableScan` instance. static Result> Create(std::unique_ptr context); + /// Create a synchronous scan with pre-allocation planning-byte admission. + /// See docs/source/user_guide/bounded_planning.rst for the admitted profile, + /// cumulative-byte contract, allocator qualification and ownership boundaries. + /// Unsupported inputs return an error without invoking the unrestricted planner. + static Result> CreateWithPlanningBudget( + std::unique_ptr context, const std::shared_ptr& budget); + virtual ~TableScan() = default; /// Create a scan plan. diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt index f92450d39..73f29c9bd 100644 --- a/src/paimon/CMakeLists.txt +++ b/src/paimon/CMakeLists.txt @@ -109,6 +109,7 @@ set(PAIMON_COMMON_SRCS common/lookup/lookup_store_factory.cpp common/memory/bytes.cpp common/memory/memory_pool.cpp + common/memory/planning_budget.cpp common/memory/memory_segment.cpp common/memory/memory_segment_utils.cpp common/memory/memory_slice.cpp @@ -477,6 +478,7 @@ set(PAIMON_CORE_SRCS core/table/source/startup_mode.cpp core/table/source/table_read.cpp core/table/source/table_scan.cpp + core/table/source/bounded_table_scan.cpp core/table/source/data_evolution_batch_scan.cpp core/table/source/primary_key_sorted_index_scan.cpp core/table/source/primary_key_sorted_index_result.cpp @@ -529,6 +531,7 @@ add_paimon_lib(paimon tbb glog fmt + zstd roaring_bitmap xxhash Threads::Threads @@ -541,6 +544,7 @@ add_paimon_lib(paimon tbb glog fmt + zstd roaring_bitmap xxhash Threads::Threads @@ -570,6 +574,41 @@ if(PAIMON_BUILD_TESTS) set_tests_properties(paimon-executor-submission-test PROPERTIES TIMEOUT 30) endif() + set(PAIMON_BOUNDED_TEST_LIB paimon_shared) + if(PAIMON_BUILD_STATIC) + set(PAIMON_BOUNDED_TEST_LIB paimon_static) + endif() + add_paimon_test(bounded_table_scan_test + SOURCES + core/table/source/bounded_table_scan_test.cpp + STATIC_LINK_LIBS + ${PAIMON_BOUNDED_TEST_LIB} + test_utils_static + zstd + ${PAIMON_LOCAL_FILE_SYSTEM_STATIC_LINK_LIBS} + ${PAIMON_PARQUET_FILE_FORMAT_STATIC_LINK_LIBS} + ${PAIMON_ORC_FILE_FORMAT_STATIC_LINK_LIBS} + ${PAIMON_AVRO_FILE_FORMAT_STATIC_LINK_LIBS} + ${GTEST_LINK_TOOLCHAIN}) + include(CheckCXXSourceCompiles) + check_cxx_source_compiles("#include +#if !defined(__GLIBCXX__) || !_GLIBCXX_USE_CXX11_ABI || _GLIBCXX_RELEASE < 9 +#error Unsupported allocation observation ABI +#endif +int main() { return 0; }" + PAIMON_PLANNING_TEST_GNU_ABI) + if(PAIMON_BUILD_STATIC + AND CMAKE_SYSTEM_NAME STREQUAL "Linux" + AND CMAKE_SIZEOF_VOID_P EQUAL 8 + AND PAIMON_PLANNING_TEST_GNU_ABI) + target_compile_definitions(paimon-bounded-table-scan-test + PRIVATE PAIMON_TEST_PLANNING_ALLOCATIONS) + # Include out-of-line basic_string constructors in the allocation hook. + target_link_options(paimon-bounded-table-scan-test PRIVATE "-static-libstdc++" + "LINKER:--wrap=_Znwm") + endif() + set_tests_properties(paimon-bounded-table-scan-test PROPERTIES TIMEOUT 120) + # The shared HTTP client is compiled only for the components that need libcurl, # so its test follows the same gate. set(PAIMON_COMMON_HTTP_CLIENT_TEST_SRCS) diff --git a/src/paimon/common/memory/planning_budget.cpp b/src/paimon/common/memory/planning_budget.cpp new file mode 100644 index 000000000..0e0929d1c --- /dev/null +++ b/src/paimon/common/memory/planning_budget.cpp @@ -0,0 +1,35 @@ +/* + * 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 "paimon/memory/planning_budget.h" + +#include + +namespace paimon { +PlanningBudget::PlanningBudget(uint64_t limit, std::shared_ptr upstream) + : limit_(limit), upstream_(std::move(upstream)) {} + +Result> PlanningBudget::Create( + uint64_t byte_limit, const std::shared_ptr& upstream) { + if (!upstream) { + return Status::Invalid("Planning budget requires an upstream allocator"); + } + return std::shared_ptr(new PlanningBudget(byte_limit, upstream)); +} +} // namespace paimon diff --git a/src/paimon/common/memory/planning_budget_internal.h b/src/paimon/common/memory/planning_budget_internal.h new file mode 100644 index 000000000..c31b1a15c --- /dev/null +++ b/src/paimon/common/memory/planning_budget_internal.h @@ -0,0 +1,143 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include +#include +#include +#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && _GLIBCXX_RELEASE >= 9 +#include +#endif +#include +#include + +#include "paimon/memory/planning_budget.h" + +namespace paimon { +class PlanningBudgetAccess { + public: + static void Reserve(const std::shared_ptr& budget, uint64_t bytes) { + if (budget->failure_.load() != 0) { + throw std::bad_alloc(); + } + uint64_t used = budget->admitted_.load(); + do { + if (bytes > budget->limit_ - used) { + uint8_t expected = 0; + budget->failure_.compare_exchange_strong(expected, 1); + throw std::bad_alloc(); + } + } while (!budget->admitted_.compare_exchange_weak(used, used + bytes)); + } + + static void* Allocate(const std::shared_ptr& budget, size_t bytes, + size_t alignment) { + Reserve(budget, bytes); + try { + void* result = budget->upstream_->Malloc(bytes, std::max(alignment, alignof(void*))); + if (bytes != 0 && result == nullptr) { + throw std::bad_alloc(); + } + return result; + } catch (const std::bad_alloc&) { + uint8_t expected = 0; + budget->failure_.compare_exchange_strong(expected, 2); + throw; + } + } + + static void Free(const std::shared_ptr& budget, void* ptr, size_t bytes, + size_t alignment) { + budget->upstream_->Free(ptr, bytes, std::max(alignment, alignof(void*))); + } + + static const std::shared_ptr& Upstream( + const std::shared_ptr& budget) { + return budget->upstream_; + } + + static void MarkAllocationFailure(const std::shared_ptr& budget) { + uint8_t expected = 0; + budget->failure_.compare_exchange_strong(expected, 2); + } + + static Status Failure(const std::shared_ptr& budget) { + if (budget->failure_.load() == 1) { + return Status::CapacityError("Native planning byte budget exhausted"); + } + return Status::OutOfMemory("Native planning allocation failed"); + } +}; + +#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && _GLIBCXX_RELEASE >= 9 +class PlanningResource : public std::pmr::memory_resource { + public: + explicit PlanningResource(std::shared_ptr budget) + : budget_(std::move(budget)) {} + const std::shared_ptr& Budget() const { + return budget_; + } + + private: + void* do_allocate(size_t bytes, size_t alignment) override { + return PlanningBudgetAccess::Allocate(budget_, bytes, alignment); + } + void do_deallocate(void* ptr, size_t bytes, size_t alignment) override { + PlanningBudgetAccess::Free(budget_, ptr, bytes, alignment); + } + bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override { + return this == &other; + } + std::shared_ptr budget_; +}; + +#endif + +template +class PlanningAllocator { + public: + using value_type = T; + explicit PlanningAllocator(std::shared_ptr budget) + : budget(std::move(budget)) {} + template + PlanningAllocator(const PlanningAllocator& other) : budget(other.budget) {} + T* allocate(size_t count) { + if (count > std::numeric_limits::max() / sizeof(T)) { + throw std::bad_alloc(); + } + return static_cast( + PlanningBudgetAccess::Allocate(budget, count * sizeof(T), alignof(T))); + } + void deallocate(T* ptr, size_t count) { + PlanningBudgetAccess::Free(budget, ptr, count * sizeof(T), alignof(T)); + } + template + bool operator==(const PlanningAllocator& other) const { + return budget == other.budget; + } + template + bool operator!=(const PlanningAllocator& other) const { + return !(*this == other); + } + std::shared_ptr budget; +}; +} // namespace paimon diff --git a/src/paimon/core/table/source/bounded_table_scan.cpp b/src/paimon/core/table/source/bounded_table_scan.cpp new file mode 100644 index 000000000..855851f9c --- /dev/null +++ b/src/paimon/core/table/source/bounded_table_scan.cpp @@ -0,0 +1,1187 @@ +/* + * 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. + */ + +#define ZSTD_STATIC_LINKING_ONLY +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "paimon/common/memory/planning_budget_internal.h" +#include "paimon/core/io/data_file_meta.h" +#include "paimon/core/manifest/file_source.h" +#include "paimon/core/stats/simple_stats.h" +#include "paimon/core/table/source/data_split_impl.h" +#include "paimon/scan_context.h" +#include "paimon/table/source/table_scan.h" +#include "rapidjson/document.h" + +namespace paimon { +#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && _GLIBCXX_RELEASE >= 9 +namespace { +constexpr size_t kMaximumDepth = 64; + +// RapidJSON's DOM AND parser stack use this allocator. No default arena is used. +class JsonAllocator { + public: + static constexpr bool kNeedFree = true; + explicit JsonAllocator(PlanningResource* resource = nullptr) : resource_(resource) {} + ~JsonAllocator() { + // RapidJSON may pop owned children before allocating their parent. Its + // exception unwinding cannot reclaim those children after admission fails. + while (head_) { + Free(head_ + 1); + } + } + void* Malloc(size_t size) { + if (size == 0) { + return nullptr; + } + if (size > std::numeric_limits::max() - sizeof(Header)) { + throw std::bad_alloc(); + } + auto* header = + static_cast(resource_->allocate(size + sizeof(Header), alignof(Header))); + header->owner = this; + header->size = size; + header->previous = nullptr; + header->next = head_; + if (head_) { + head_->previous = header; + } + head_ = header; + return header + 1; + } + void* Realloc(void* ptr, size_t, size_t size) { + if (!ptr) { + return Malloc(size); + } + if (size == 0) { + Free(ptr); + return nullptr; + } + auto* old = static_cast(ptr) - 1; + void* result = Malloc(size); + memcpy(result, ptr, std::min(size, old->size)); + Free(ptr); + return result; + } + static void Free(void* ptr) { + if (ptr) { + auto* header = static_cast(ptr) - 1; + auto* owner = header->owner; + if (header->previous) { + header->previous->next = header->next; + } else { + owner->head_ = header->next; + } + if (header->next) { + header->next->previous = header->previous; + } + owner->resource_->deallocate(header, header->size + sizeof(Header), alignof(Header)); + } + } + + private: + struct alignas(std::max_align_t) Header { + JsonAllocator* owner; + size_t size; + Header* previous; + Header* next; + }; + PlanningResource* resource_; + Header* head_ = nullptr; +}; +using Json = rapidjson::GenericValue, JsonAllocator>; +using Document = rapidjson::GenericDocument, JsonAllocator, JsonAllocator>; + +const Json* Member(const Json& object, std::string_view name) { + if (!object.IsObject()) { + return nullptr; + } + auto it = object.FindMember(rapidjson::GenericStringRef(name.data(), name.size())); + return it == object.MemberEnd() ? nullptr : &it->value; +} +std::string_view Text(const Json& value) { + return {value.GetString(), value.GetStringLength()}; +} + +class JsonInput { + public: + explicit JsonInput(PlanningResource* resource) + : allocator_(resource), document_(&allocator_, 256, &allocator_) {} + Status Parse(std::string_view input) { + size_t depth = 0; + bool quoted = false; + bool escaped = false; + for (char ch : input) { + if (ch == '\0') { + return Status::Invalid("Embedded NUL in planning JSON"); + } + if (quoted) { + if (escaped) { + escaped = false; + } else if (ch == '\\') { + escaped = true; + } else if (ch == '"') { + quoted = false; + } + } else if (ch == '"') { + quoted = true; + } else if (ch == '{' || ch == '[') { + if (++depth > kMaximumDepth) { + return Status::Invalid("Planning JSON nesting limit exceeded"); + } + } else if (ch == '}' || ch == ']') { + if (depth == 0) { + return Status::Invalid("Unbalanced planning JSON"); + } + --depth; + } + } + document_.Parse( + input.data(), input.size()); + if (document_.HasParseError()) { + return Status::Invalid("Invalid planning JSON"); + } + return UniqueMembers(document_); + } + const Json& Root() const { + return document_; + } + + private: + static Status UniqueMembers(const Json& value) { + if (value.IsObject()) { + for (auto member = value.MemberBegin(); member != value.MemberEnd(); ++member) { + for (auto previous = value.MemberBegin(); previous != member; ++previous) { + if (Text(previous->name) == Text(member->name)) { + return Status::Invalid("Duplicate planning JSON member"); + } + } + PAIMON_RETURN_NOT_OK(UniqueMembers(member->value)); + } + } else if (value.IsArray()) { + for (const auto& item : value.GetArray()) { + PAIMON_RETURN_NOT_OK(UniqueMembers(item)); + } + } + return Status::OK(); + } + JsonAllocator allocator_; + Document document_; +}; + +class FileDescriptor { + public: + explicit FileDescriptor(int32_t fd) : fd_(fd) {} + ~FileDescriptor() { + if (fd_ >= 0) { + ::close(fd_); + } + } + int32_t Get() const { + return fd_; + } + Status Close() { + int32_t fd = std::exchange(fd_, -1); + if (::close(fd) != 0) { + return Status::IOError("Cannot close planning input"); + } + return Status::OK(); + } + + private: + int32_t fd_; +}; + +Result> ReadFile(const std::pmr::string& path, PlanningResource* resource) { + FileDescriptor fd(::open(path.c_str(), O_RDONLY | O_CLOEXEC)); + if (fd.Get() < 0) { + return Status::IOError("Cannot open bound planning input"); + } + struct stat info{}; + if (::fstat(fd.Get(), &info) != 0 || !S_ISREG(info.st_mode) || info.st_size < 0) { + return Status::IOError("Invalid bound planning input"); + } + if (static_cast(info.st_size) > static_cast(PTRDIFF_MAX)) { + return Status::CapacityError("Planning input length exceeds addressable storage"); + } + std::pmr::vector bytes(static_cast(info.st_size), resource); + size_t done = 0; + while (done < bytes.size()) { + ssize_t count = ::read(fd.Get(), bytes.data() + done, + std::min(bytes.size() - done, static_cast(SSIZE_MAX))); + if (count < 0 && errno == EINTR) { + continue; + } + if (count <= 0) { + return Status::IOError("Truncated bound planning input"); + } + done += count; + } + // Refuse changing inputs rather than accepting a prefix of a file which grew. + char extra; + ssize_t tail; + do { + tail = ::read(fd.Get(), &extra, 1); + } while (tail < 0 && errno == EINTR); + if (tail != 0) { + return Status::IOError("Bound planning input changed while reading"); + } + PAIMON_RETURN_NOT_OK(fd.Close()); + return bytes; +} + +bool Component(std::string_view value) { + return !value.empty() && value != "." && value != ".." && + value.find_first_of("/\\\0", 0, 3) == std::string_view::npos; +} + +class Cursor { + public: + explicit Cursor(std::string_view data) : data_(data) {} + size_t Remaining() const { + return data_.size() - position_; + } + size_t Position() const { + return position_; + } + Result Take(uint64_t count) { + if (count > Remaining()) { + return Status::Invalid("Truncated Avro metadata"); + } + auto result = data_.substr(position_, count); + position_ += count; + return result; + } + Result Long() { + uint64_t bits = 0; + for (uint32_t shift = 0; shift < 70; shift += 7) { + PAIMON_ASSIGN_OR_RAISE(std::string_view byte, Take(1)); + uint8_t value = static_cast(byte[0]); + if (shift == 63 && value > 1) { + return Status::Invalid("Avro integer overflow"); + } + bits |= static_cast(value & 127) << shift; + if ((value & 128) == 0) { + return static_cast(bits >> 1) ^ -static_cast(bits & 1); + } + } + return Status::Invalid("Avro integer overflow"); + } + Result Bytes() { + PAIMON_ASSIGN_OR_RAISE(int64_t size, Long()); + if (size < 0) { + return Status::Invalid("Negative Avro byte length"); + } + return Take(size); + } + + private: + std::string_view data_; + size_t position_ = 0; +}; + +struct Datum { + enum class Kind { Null, Integer, Bytes, Record, Array, Unsupported }; + explicit Datum(PlanningResource* resource) : children(resource) {} + Kind kind = Kind::Null; + int64_t number = 0; + std::string_view bytes; + const Json* schema = nullptr; + std::pmr::vector children; + const Datum* Find(std::string_view name) const { + if (kind != Kind::Record) { + return nullptr; + } + const auto* fields = Member(*schema, "fields"); + if (!fields || !fields->IsArray()) { + return nullptr; + } + for (size_t i = 0; i < fields->Size(); ++i) { + const Json* field_name = Member((*fields)[i], "name"); + if (field_name && field_name->IsString() && Text(*field_name) == name) { + return &children[i]; + } + } + return nullptr; + } +}; + +Result Integer(const Datum& row, std::string_view field) { + const Datum* value = row.Find(field); + if (!value || value->kind != Datum::Kind::Integer) { + return Status::Invalid("Missing integer metadata field"); + } + return value->number; +} +Result String(const Datum& row, std::string_view field) { + const Datum* value = row.Find(field); + if (!value || value->kind != Datum::Kind::Bytes) { + return Status::Invalid("Missing byte metadata field"); + } + return value->bytes; +} +bool Absent(const Datum& row, std::string_view field) { + const Datum* value = row.Find(field); + return !value || value->kind == Datum::Kind::Null; +} +bool EmptyArray(const Datum& row, std::string_view field) { + const Datum* value = row.Find(field); + return Absent(row, field) || (value->kind == Datum::Kind::Array && value->children.empty()); +} +Result> OptionalInteger(const Datum& row, std::string_view field) { + const Datum* value = row.Find(field); + if (!value || value->kind == Datum::Kind::Null) { + return std::optional(); + } + if (value->kind != Datum::Kind::Integer) { + return Status::Invalid("Invalid optional integer metadata"); + } + return std::optional(value->number); +} + +class AvroSchema { + public: + explicit AvroSchema(PlanningResource* resource) + : json_(resource), names_(resource), resource_(resource) {} + Status Parse(std::string_view text) { + PAIMON_RETURN_NOT_OK(json_.Parse(text)); + return Collect(json_.Root(), 0); + } + const Json& Root() const { + return json_.Root(); + } + Result Read(const Json& schema, Cursor* cursor, size_t depth = 0, + bool skip = false) const { + if (depth > kMaximumDepth) { + return Status::Invalid("Recursive Avro metadata schema"); + } + Datum result(resource_); + if (schema.IsArray()) { + PAIMON_ASSIGN_OR_RAISE(int64_t branch, cursor->Long()); + if (branch < 0 || static_cast(branch) >= schema.Size()) { + return Status::Invalid("Invalid Avro union branch"); + } + return Read(schema[branch], cursor, depth + 1, skip); + } + const Json* type = schema.IsObject() ? Member(schema, "type") : &schema; + if (!type || !type->IsString()) { + return Status::Invalid("Invalid Avro metadata type"); + } + std::string_view name = Text(*type); + if (name == "null") { + return result; + } + if (name == "int" || name == "long") { + PAIMON_ASSIGN_OR_RAISE(result.number, cursor->Long()); + if (name == "int" && (result.number < INT32_MIN || result.number > INT32_MAX)) { + return Status::Invalid("Avro int overflow"); + } + result.kind = Datum::Kind::Integer; + } else if (name == "boolean") { + PAIMON_ASSIGN_OR_RAISE(std::string_view byte, cursor->Take(1)); + if (static_cast(byte[0]) > 1) { + return Status::Invalid("Invalid Avro boolean"); + } + result.kind = Datum::Kind::Unsupported; + result.number = byte[0]; + } else if (name == "string" || name == "bytes") { + PAIMON_ASSIGN_OR_RAISE(result.bytes, cursor->Bytes()); + result.kind = Datum::Kind::Bytes; + } else if (name == "float" || name == "double") { + result.kind = Datum::Kind::Unsupported; + PAIMON_RETURN_NOT_OK(cursor->Take(name == "float" ? 4 : 8).status()); + } else if (name == "record") { + const Json* fields = Member(schema, "fields"); + if (!fields || !fields->IsArray()) { + return Status::Invalid("Invalid Avro record fields"); + } + result.kind = Datum::Kind::Record; + result.schema = &schema; + if (!skip) { + result.children.reserve(fields->Size()); + } + for (const auto& field : fields->GetArray()) { + const Json* child = Member(field, "type"); + const Json* field_name = Member(field, "name"); + if (!child || !field_name || !field_name->IsString()) { + return Status::Invalid("Invalid Avro record field"); + } + auto label = Text(*field_name); + const bool discard = skip || label == "_KEY_STATS" || label == "_VALUE_STATS" || + label == "_PARTITION_STATS"; + PAIMON_ASSIGN_OR_RAISE(Datum value, Read(*child, cursor, depth + 1, discard)); + if (!skip) { + result.children.push_back(std::move(value)); + } + } + } else if (name == "array" || name == "map") { + const Json* item = Member(schema, name == "array" ? "items" : "values"); + if (!item) { + return Status::Invalid("Invalid Avro collection schema"); + } + result.kind = Datum::Kind::Array; + while (true) { + PAIMON_ASSIGN_OR_RAISE(int64_t count, cursor->Long()); + if (count == 0) { + break; + } + std::optional end; + if (count < 0) { + if (count == INT64_MIN) { + return Status::Invalid("Avro collection count overflow"); + } + count = -count; + PAIMON_ASSIGN_OR_RAISE(int64_t size, cursor->Long()); + if (size < 0 || static_cast(size) > cursor->Remaining()) { + return Status::Invalid("Invalid Avro collection block size"); + } + end = cursor->Position() + size; + } + // Admitted metadata arrays contain a union tag or scalar per item. + if (static_cast(count) > cursor->Remaining() + 1) { + return Status::Invalid("Impossible Avro collection count"); + } + for (int64_t i = 0; i < count; ++i) { + if (name == "map") { + PAIMON_RETURN_NOT_OK(cursor->Bytes().status()); + } + PAIMON_ASSIGN_OR_RAISE(Datum value, Read(*item, cursor, depth + 1, skip)); + if (!skip) { + result.children.push_back(std::move(value)); + } + } + if (end && cursor->Position() != *end) { + return Status::Invalid("Avro collection block size mismatch"); + } + } + } else if (name == "fixed") { + const Json* size = Member(schema, "size"); + if (!size || !size->IsUint64()) { + return Status::Invalid("Invalid Avro fixed size"); + } + PAIMON_ASSIGN_OR_RAISE(result.bytes, cursor->Take(size->GetUint64())); + result.kind = Datum::Kind::Bytes; + } else if (name == "enum") { + const Json* symbols = Member(schema, "symbols"); + PAIMON_ASSIGN_OR_RAISE(result.number, cursor->Long()); + if (!symbols || !symbols->IsArray() || result.number < 0 || + static_cast(result.number) >= symbols->Size()) { + return Status::Invalid("Invalid Avro enum"); + } + result.kind = Datum::Kind::Unsupported; + } else { + auto it = names_.find(name); + if (it == names_.end()) { + return Status::NotImplemented("Unknown Avro metadata schema reference"); + } + return Read(*it->second, cursor, depth + 1, skip); + } + return result; + } + + private: + Status Collect(const Json& node, size_t depth) { + if (depth > kMaximumDepth) { + return Status::Invalid("Avro schema nesting limit exceeded"); + } + if (node.IsArray()) { + for (const auto& item : node.GetArray()) { + PAIMON_RETURN_NOT_OK(Collect(item, depth + 1)); + } + } else if (node.IsObject()) { + const Json* type = Member(node, "type"); + if (!type) { + return Status::Invalid("Missing Avro schema type"); + } + const Json* name = Member(node, "name"); + if (name) { + if (!name->IsString() || !names_.emplace(Text(*name), &node).second) { + return Status::Invalid("Duplicate Avro metadata type name"); + } + } + if (type->IsString() && Text(*type) == "record") { + const Json* fields = Member(node, "fields"); + if (!fields || !fields->IsArray()) { + return Status::Invalid("Invalid Avro record schema"); + } + for (auto field = fields->Begin(); field != fields->End(); ++field) { + const Json* child = Member(*field, "type"); + const Json* label = Member(*field, "name"); + if (!child || !label || !label->IsString()) { + return Status::Invalid("Missing Avro field name or type"); + } + for (auto previous = fields->Begin(); previous != field; ++previous) { + const Json* other = Member(*previous, "name"); + if (other && other->IsString() && Text(*other) == Text(*label)) { + return Status::Invalid("Duplicate Avro record field name"); + } + } + PAIMON_RETURN_NOT_OK(Collect(*child, depth + 1)); + } + } else { + for (auto label : {"items", "values"}) { + const Json* child = Member(node, label); + if (child) { + PAIMON_RETURN_NOT_OK(Collect(*child, depth + 1)); + } + } + } + } else if (!node.IsString()) { + return Status::Invalid("Invalid Avro schema"); + } + return Status::OK(); + } + JsonInput json_; + std::pmr::unordered_map names_; + PlanningResource* resource_; +}; + +// Validate known fields against their writer schema even for null/empty values. +// Value-only checks cannot distinguish an absent optional from an ignored float. +const Json* Unwrap(const Json& type) { + if (!type.IsArray()) { + return &type; + } + if (type.Size() != 2) { + return nullptr; + } + for (size_t i = 0; i < 2; ++i) { + if (type[i].IsString() && Text(type[i]) == "null") { + return &type[1 - i]; + } + } + return nullptr; +} +bool Matches(const Json& schema, std::string_view expected, std::string_view items = {}) { + const Json* value = Unwrap(schema); + if (!value) { + return false; + } + const Json* type = value->IsObject() ? Member(*value, "type") : value; + if (!type || !type->IsString() || Text(*type) != expected) { + return false; + } + if (!items.empty()) { + const Json* item = Member(*value, "items"); + return item && Matches(*item, items); + } + return true; +} +const Json* FieldType(const Json& record, std::string_view name) { + const Json* value = Unwrap(record); + const Json* fields = value ? Member(*value, "fields") : nullptr; + if (!fields || !fields->IsArray()) { + return nullptr; + } + for (const auto& field : fields->GetArray()) { + const Json* label = Member(field, "name"); + if (label && label->IsString() && Text(*label) == name) { + return Member(field, "type"); + } + } + return nullptr; +} +Status ValidateMetadataSchema(const Json& root, bool manifest_entry) { + if (!Matches(root, "record")) { + return Status::Invalid("Metadata writer root must be a record"); + } + auto required = [](const Json& record, std::string_view name, std::string_view type) -> Status { + const Json* field = FieldType(record, name); + if (!field || !Matches(*field, type)) { + return Status::Invalid("Unsupported required metadata writer field type"); + } + return Status::OK(); + }; + PAIMON_RETURN_NOT_OK(required(root, "_VERSION", "int")); + if (!manifest_entry) { + return required(root, "_FILE_NAME", "string"); + } + PAIMON_RETURN_NOT_OK(required(root, "_KIND", "int")); + PAIMON_RETURN_NOT_OK(required(root, "_PARTITION", "bytes")); + PAIMON_RETURN_NOT_OK(required(root, "_BUCKET", "int")); + PAIMON_RETURN_NOT_OK(required(root, "_TOTAL_BUCKETS", "int")); + PAIMON_RETURN_NOT_OK(required(root, "_FILE", "record")); + const Json& file = *FieldType(root, "_FILE"); + PAIMON_RETURN_NOT_OK(required(file, "_FILE_NAME", "string")); + PAIMON_RETURN_NOT_OK(required(file, "_LEVEL", "int")); + for (auto name : {"_FILE_SIZE", "_ROW_COUNT", "_SCHEMA_ID", "_MIN_SEQUENCE_NUMBER", + "_MAX_SEQUENCE_NUMBER"}) { + PAIMON_RETURN_NOT_OK(required(file, name, "long")); + } + for (auto [name, type] : {std::pair{"_CREATION_TIME", "long"}, + {"_DELETE_ROW_COUNT", "long"}, + {"_FILE_SOURCE", "int"}, + {"_FIRST_ROW_ID", "long"}, + {"_EXTERNAL_PATH", "string"}, + {"_EMBEDDED_FILE_INDEX", "bytes"}}) { + if (const Json* field = FieldType(file, name); field && !Matches(*field, type)) { + return Status::Invalid("Unsupported optional metadata writer field type"); + } + } + for (auto name : + {"_EXTRA_FILES", "_VALUE_STATS_COLS", "_WRITE_COLS", "_WRITE_COLS_SEQUENCES"}) { + const Json* field = FieldType(file, name); + if (field && + !Matches(*field, "array", + std::string_view(name) == "_WRITE_COLS_SEQUENCES" ? "long" : "string")) { + return Status::Invalid("Unsupported collection metadata writer field type"); + } + } + return Status::OK(); +} + +template +Status ReadOcf(const std::pmr::string& path, PlanningResource* resource, bool manifest_entry, + Consumer consumer) { + PAIMON_ASSIGN_OR_RAISE(std::pmr::vector file, ReadFile(path, resource)); + Cursor cursor({file.data(), file.size()}); + PAIMON_ASSIGN_OR_RAISE(std::string_view magic, cursor.Take(4)); + if (magic != std::string_view("Obj\1", 4)) { + return Status::NotImplemented("Bounded planning requires Avro OCF metadata"); + } + std::string_view schema_text; + std::string_view codec = "null"; + bool has_schema = false; + bool has_codec = false; + while (true) { + PAIMON_ASSIGN_OR_RAISE(int64_t count, cursor.Long()); + if (count == 0) { + break; + } + std::optional end; + if (count < 0) { + if (count == INT64_MIN) { + return Status::Invalid("Avro header count overflow"); + } + count = -count; + PAIMON_ASSIGN_OR_RAISE(int64_t size, cursor.Long()); + if (size < 0 || static_cast(size) > cursor.Remaining()) { + return Status::Invalid("Invalid Avro header block"); + } + end = cursor.Position() + size; + } + if (static_cast(count) > cursor.Remaining() / 2) { + return Status::Invalid("Impossible Avro header count"); + } + for (int64_t i = 0; i < count; ++i) { + PAIMON_ASSIGN_OR_RAISE(std::string_view key, cursor.Bytes()); + PAIMON_ASSIGN_OR_RAISE(std::string_view value, cursor.Bytes()); + if (key == "avro.schema") { + if (has_schema) { + return Status::Invalid("Duplicate Avro schema header"); + } + schema_text = value; + has_schema = true; + } else if (key == "avro.codec") { + if (has_codec) { + return Status::Invalid("Duplicate Avro codec header"); + } + codec = value; + has_codec = true; + } + } + if (end && cursor.Position() != *end) { + return Status::Invalid("Avro header block size mismatch"); + } + } + if (!has_schema) { + return Status::Invalid("Missing Avro metadata schema"); + } + if (codec != "null" && codec != "zstandard") { + return Status::NotImplemented("Unsupported bounded metadata codec"); + } + PAIMON_ASSIGN_OR_RAISE(std::string_view sync, cursor.Take(16)); + AvroSchema schema(resource); + PAIMON_RETURN_NOT_OK(schema.Parse(schema_text)); + PAIMON_RETURN_NOT_OK(ValidateMetadataSchema(schema.Root(), manifest_entry)); + std::pmr::vector workspace(resource); + while (cursor.Remaining() != 0) { + PAIMON_ASSIGN_OR_RAISE(int64_t count, cursor.Long()); + PAIMON_ASSIGN_OR_RAISE(int64_t length, cursor.Long()); + if (count <= 0 || length < 0) { + return Status::Invalid("Invalid Avro data block"); + } + PAIMON_ASSIGN_OR_RAISE(std::string_view compressed, cursor.Take(length)); + PAIMON_ASSIGN_OR_RAISE(std::string_view marker, cursor.Take(16)); + if (marker != sync) { + return Status::Invalid("Avro sync marker mismatch"); + } + std::pmr::vector output(resource); + std::string_view payload = compressed; + if (codec == "zstandard") { + if (ZSTD_getFrameContentSize(compressed.data(), compressed.size()) == + ZSTD_CONTENTSIZE_ERROR || + ZSTD_getDictID_fromFrame(compressed.data(), compressed.size()) != 0 || + ZSTD_findFrameCompressedSize(compressed.data(), compressed.size()) != + compressed.size()) { + return Status::Invalid("Invalid bounded Zstandard frame"); + } + const size_t required = + ZSTD_estimateDStreamSize_fromFrame(compressed.data(), compressed.size()); + if (ZSTD_isError(required) || + required > static_cast(PTRDIFF_MAX) - sizeof(uint64_t)) { + return Status::Invalid("Invalid bounded Zstandard workspace size"); + } + workspace.resize((required + sizeof(uint64_t) - 1) / sizeof(uint64_t)); + auto* stream = + ZSTD_initStaticDStream(workspace.data(), workspace.size() * sizeof(uint64_t)); + if (!stream || ZSTD_isError(ZSTD_initDStream(stream))) { + return Status::Invalid("Cannot initialize bounded Zstandard decoder"); + } + ZSTD_inBuffer input{compressed.data(), compressed.size(), 0}; + size_t remaining = 1; + while (remaining != 0) { + const size_t before = output.size(); + if (before > static_cast(PTRDIFF_MAX) - 65536) { + return Status::CapacityError("Decoded metadata exceeds addressable storage"); + } + output.resize(before + 65536); + ZSTD_outBuffer destination{output.data() + before, output.size() - before, 0}; + const size_t consumed = input.pos; + remaining = ZSTD_decompressStream(stream, &destination, &input); + if (ZSTD_isError(remaining) || + (remaining != 0 && input.pos == consumed && destination.pos == 0)) { + return Status::Invalid("Cannot decode bounded Zstandard frame"); + } + output.resize(before + destination.pos); + } + if (input.pos != input.size) { + return Status::Invalid("Trailing bounded Zstandard frame bytes"); + } + payload = {output.data(), output.size()}; + } + if (static_cast(count) > payload.size()) { + return Status::Invalid("Impossible Avro record count"); + } + Cursor records(payload); + for (int64_t i = 0; i < count; ++i) { + PAIMON_ASSIGN_OR_RAISE(Datum row, schema.Read(schema.Root(), &records)); + PAIMON_RETURN_NOT_OK(consumer(row)); + } + if (records.Remaining() != 0) { + return Status::Invalid("Avro record count or block size mismatch"); + } + } + return Status::OK(); +} + +// The only ordinary-STL variable allocations in finite planning are exact-size +// native publication constructors. This profile is qualified for the libstdc++ +// C++11 ABI: basic_string's forward-range/copy constructors request n+1 chars; +// vector(n) requests n elements. See the source/allocator-observed proof in docs. +std::string NativeString(std::string_view text, const std::shared_ptr& budget) { + PlanningBudgetAccess::Reserve(budget, text.size() + 1); + return std::string(text.data(), text.size()); +} + +struct Entry { + explicit Entry(PlanningResource* resource) : name(resource) {} + std::pmr::string name; + int32_t bucket = 0; + int32_t total_buckets = 0; + int32_t level = 0; + int64_t file_size = 0; + int64_t rows = 0; + int64_t schema_id = 0; + int64_t min_sequence = 0; + int64_t max_sequence = 0; + int64_t creation = 0; + std::optional first_row; + std::optional source; +}; + +class BudgetPlan : public Plan { + public: + BudgetPlan(int64_t snapshot, std::vector> splits) + : snapshot_(snapshot), splits_(std::move(splits)) {} + std::optional SnapshotId() const override { + return snapshot_; + } + const std::vector>& Splits() const override { + return splits_; + } + + private: + int64_t snapshot_; + std::vector> splits_; +}; + +class BoundedTableScan : public TableScan { + public: + BoundedTableScan(std::unique_ptr context, std::shared_ptr budget) + : context_(std::move(context)), resource_(std::move(budget)) {} + + Result> CreatePlan() override { + if (failure_) { + return *failure_; + } + if (finished_) { + return Status::Invalid("end of scan"); + } + finished_ = true; + try { + auto result = BuildPlan(); + if (!result.ok()) { + failure_ = result.status(); + } + return result; + } catch (const std::bad_alloc&) { + PlanningBudgetAccess::MarkAllocationFailure(resource_.Budget()); + failure_ = PlanningBudgetAccess::Failure(resource_.Budget()); + } catch (const std::length_error&) { + failure_ = + Status::CapacityError("Planning container length exceeds addressable storage"); + } + return *failure_; + } + + private: + std::string_view Option(const Json& schema, std::string_view key, + std::string_view fallback = {}) const { + for (const auto& item : context_->GetOptions()) { + if (item.first == key) { + return item.second; + } + } + const Json* options = Member(schema, "options"); + const Json* value = options ? Member(*options, key) : nullptr; + return value && value->IsString() ? Text(*value) : fallback; + } + Result> BuildPlan() { + JsonInput schema(&resource_); + PAIMON_RETURN_NOT_OK(schema.Parse(*context_->GetSpecificTableSchema())); + const Json& table = schema.Root(); + const Json* schema_id = Member(table, "id"); + const Json* keys = Member(table, "primaryKeys"); + const Json* partitions = Member(table, "partitionKeys"); + if (!schema_id || !schema_id->IsInt64() || !keys || !keys->IsArray() || !keys->Empty() || + !partitions || !partitions->IsArray() || !partitions->Empty()) { + return Status::NotImplemented( + "Bounded planning requires an unpartitioned append-only schema"); + } + if (Option(table, "bucket", "-1") != "-1" || Option(table, "branch", "main") != "main" || + Option(table, "manifest.format", "avro") != "avro" || + Option(table, "data-evolution.enabled", "false") != "false" || + Option(table, "deletion-vectors.enabled", "false") != "false") { + return Status::NotImplemented("Unsupported bounded planning profile"); + } + auto check_option = [](std::string_view key) -> Status { + if (key.substr(0, 5) == "scan." && key != "scan.snapshot-id") { + return Status::NotImplemented( + "Bounded planning requires explicit numeric snapshot selection"); + } + return Status::OK(); + }; + if (const Json* options = Member(table, "options")) { + if (!options->IsObject()) { + return Status::Invalid("Invalid retained schema options"); + } + for (const auto& item : options->GetObject()) { + if (!item.value.IsString()) { + return Status::Invalid("Invalid retained schema option value"); + } + PAIMON_RETURN_NOT_OK(check_option(Text(item.name))); + } + } + for (const auto& item : context_->GetOptions()) { + PAIMON_RETURN_NOT_OK(check_option(item.first)); + } + auto format = Option(table, "file.format", "parquet"); + if (format != "parquet" && format != "orc") { + return Status::NotImplemented("Unsupported bounded data format"); + } + std::string_view snapshot_text = Option(table, "scan.snapshot-id"); + int64_t snapshot_id = 0; + if (snapshot_text.empty()) { + return Status::Invalid("Missing bound numeric snapshot id"); + } + auto parsed = std::from_chars(snapshot_text.data(), + snapshot_text.data() + snapshot_text.size(), snapshot_id); + if (parsed.ec != std::errc() || parsed.ptr != snapshot_text.data() + snapshot_text.size() || + snapshot_id < 1) { + return Status::Invalid("Bounded planning requires a positive numeric snapshot id"); + } + std::pmr::string root(context_->GetPath(), &resource_); + if (std::string_view(root).substr(0, 7) == "file://") { + root.erase(0, 7); + } + if (root.find("://") != std::pmr::string::npos || root.empty()) { + return Status::NotImplemented("Bounded planning currently supports local files"); + } + while (root.size() > 1 && root.back() == '/') { + root.pop_back(); + } + std::pmr::string snapshot_path(root, &resource_); + snapshot_path += "/snapshot/snapshot-"; + char id[32]; + auto id_result = std::to_chars(id, id + sizeof(id), snapshot_id); + snapshot_path.append(id, id_result.ptr); + PAIMON_ASSIGN_OR_RAISE(std::pmr::vector snapshot_bytes, + ReadFile(snapshot_path, &resource_)); + JsonInput snapshot(&resource_); + PAIMON_RETURN_NOT_OK(snapshot.Parse({snapshot_bytes.data(), snapshot_bytes.size()})); + const Json* actual_id = Member(snapshot.Root(), "id"); + const Json* actual_schema = Member(snapshot.Root(), "schemaId"); + if (!actual_id || !actual_id->IsInt64() || actual_id->GetInt64() != snapshot_id || + !actual_schema || !actual_schema->IsInt64() || + actual_schema->GetInt64() != schema_id->GetInt64()) { + return Status::Invalid("Bound snapshot/schema identity mismatch"); + } + std::pmr::vector manifests(&resource_); + for (auto label : {"baseManifestList", "deltaManifestList"}) { + const Json* name = Member(snapshot.Root(), label); + if (!name || !name->IsString() || !Component(Text(*name))) { + return Status::Invalid("Invalid bound manifest-list identity"); + } + std::pmr::string path(root, &resource_); + path += "/manifest/"; + path.append(name->GetString(), name->GetStringLength()); + PAIMON_RETURN_NOT_OK(ReadOcf(path, &resource_, false, [&](const Datum& row) -> Status { + PAIMON_ASSIGN_OR_RAISE(int64_t version, Integer(row, "_VERSION")); + PAIMON_ASSIGN_OR_RAISE(std::string_view filename, String(row, "_FILE_NAME")); + if (version != 2 || !Component(filename)) { + return Status::Invalid("Invalid manifest-list record"); + } + manifests.emplace_back(filename); + return Status::OK(); + })); + } + std::pmr::vector entries(&resource_); + for (const auto& manifest : manifests) { + std::pmr::string path(root, &resource_); + path += "/manifest/"; + path += manifest; + PAIMON_RETURN_NOT_OK(ReadOcf(path, &resource_, true, [&](const Datum& row) -> Status { + PAIMON_ASSIGN_OR_RAISE(int64_t version, Integer(row, "_VERSION")); + PAIMON_ASSIGN_OR_RAISE(int64_t kind, Integer(row, "_KIND")); + if (version != 2) { + return Status::NotImplemented("Unsupported manifest version"); + } + if (kind == 1) { + return Status::NotImplemented( + "Bounded ADD-only profile does not admit delete manifests"); + } + if (kind != 0) { + return Status::Invalid("Invalid manifest row kind"); + } + PAIMON_ASSIGN_OR_RAISE(std::string_view partition, String(row, "_PARTITION")); + if (partition.size() != 12 || std::any_of(partition.begin(), partition.end(), + [](char c) { return c != 0; })) { + return Status::NotImplemented("Bounded planning requires empty partitions"); + } + const Datum* file = row.Find("_FILE"); + if (!file || file->kind != Datum::Kind::Record) { + return Status::Invalid("Missing file metadata"); + } + Entry entry(&resource_); + PAIMON_ASSIGN_OR_RAISE(std::string_view filename, String(*file, "_FILE_NAME")); + const auto dot = filename.find_last_of('.'); + if (!Component(filename) || dot == std::string_view::npos || + filename.substr(dot + 1) != format) { + return Status::NotImplemented( + "Bounded planning requires Parquet or ORC data files"); + } + entry.name = filename; + PAIMON_ASSIGN_OR_RAISE(int64_t bucket, Integer(row, "_BUCKET")); + PAIMON_ASSIGN_OR_RAISE(int64_t total, Integer(row, "_TOTAL_BUCKETS")); + PAIMON_ASSIGN_OR_RAISE(int64_t level, Integer(*file, "_LEVEL")); + if (bucket < 0 || bucket > INT32_MAX || total < INT32_MIN || total > INT32_MAX || + level < 0 || level > INT32_MAX) { + return Status::Invalid("Invalid file bucket or level"); + } + entry.bucket = bucket; + entry.total_buckets = total; + entry.level = level; + PAIMON_ASSIGN_OR_RAISE(entry.file_size, Integer(*file, "_FILE_SIZE")); + PAIMON_ASSIGN_OR_RAISE(entry.rows, Integer(*file, "_ROW_COUNT")); + PAIMON_ASSIGN_OR_RAISE(entry.schema_id, Integer(*file, "_SCHEMA_ID")); + PAIMON_ASSIGN_OR_RAISE(entry.min_sequence, Integer(*file, "_MIN_SEQUENCE_NUMBER")); + PAIMON_ASSIGN_OR_RAISE(entry.max_sequence, Integer(*file, "_MAX_SEQUENCE_NUMBER")); + PAIMON_ASSIGN_OR_RAISE(entry.first_row, OptionalInteger(*file, "_FIRST_ROW_ID")); + PAIMON_ASSIGN_OR_RAISE(std::optional creation, + OptionalInteger(*file, "_CREATION_TIME")); + entry.creation = creation.value_or(0); + PAIMON_ASSIGN_OR_RAISE(std::optional deleted, + OptionalInteger(*file, "_DELETE_ROW_COUNT")); + PAIMON_ASSIGN_OR_RAISE(std::optional source, + OptionalInteger(*file, "_FILE_SOURCE")); + if (source) { + if (*source < 0 || *source > 1) { + return Status::Invalid("Invalid data file source"); + } + PAIMON_ASSIGN_OR_RAISE(entry.source, FileSource::FromByteValue(*source)); + } + if (entry.file_size < 0 || entry.rows < 0 || (deleted && *deleted != 0) || + (entry.first_row && + (*entry.first_row < 0 || entry.rows > INT64_MAX - *entry.first_row)) || + entry.schema_id != schema_id->GetInt64() || + !EmptyArray(*file, "_EXTRA_FILES") || !Absent(*file, "_EMBEDDED_FILE_INDEX") || + !Absent(*file, "_EXTERNAL_PATH") || !Absent(*file, "_WRITE_COLS") || + !Absent(*file, "_WRITE_COLS_SEQUENCES")) { + return Status::NotImplemented("Unsupported bounded data-file metadata"); + } + entries.push_back(std::move(entry)); + return Status::OK(); + })); + } + std::pmr::vector sorted(entries.size(), &resource_); + for (size_t i = 0; i < entries.size(); ++i) { + sorted[i] = i; + } + auto key = [&](size_t i) { + return std::tie(entries[i].bucket, entries[i].level, entries[i].name); + }; + std::sort(sorted.begin(), sorted.end(), + [&](size_t left, size_t right) { return key(left) < key(right); }); + for (size_t i = 1; i < sorted.size(); ++i) { + if (key(sorted[i - 1]) == key(sorted[i])) { + return Status::Invalid("Duplicate ADD metadata file"); + } + } + if (entries.size() > SIZE_MAX / sizeof(std::shared_ptr)) { + throw std::bad_alloc(); + } + PlanningBudgetAccess::Reserve(resource_.Budget(), + entries.size() * sizeof(std::shared_ptr)); + std::vector> splits(entries.size()); + for (size_t i = 0; i < entries.size(); ++i) { + const Entry& entry = entries[i]; + std::string filename = NativeString(entry.name, resource_.Budget()); + // DataFileMeta copies its sole nonempty string. Other admitted fields + // below are empty/optional scalars; the shared block is allocator-routed. + PlanningBudgetAccess::Reserve(resource_.Budget(), entry.name.size() + 1); + auto meta = std::allocate_shared( + PlanningAllocator(resource_.Budget()), filename, entry.file_size, + entry.rows, BinaryRow::EmptyRow(), BinaryRow::EmptyRow(), SimpleStats::EmptyStats(), + SimpleStats::EmptyStats(), entry.min_sequence, entry.max_sequence, entry.schema_id, + entry.level, std::vector>(), + Timestamp(entry.creation, 0), std::optional(0), nullptr, entry.source, + std::nullopt, std::nullopt, entry.first_row, std::nullopt, std::nullopt); + PlanningBudgetAccess::Reserve(resource_.Budget(), + sizeof(std::shared_ptr)); + std::vector> files(1); + files[0] = std::move(meta); + std::pmr::string bucket_path(root, &resource_); + bucket_path += "/bucket-"; + char bucket_digits[32]; + auto number = + std::to_chars(bucket_digits, bucket_digits + sizeof(bucket_digits), entry.bucket); + bucket_path.append(bucket_digits, number.ptr); + std::string native_path = NativeString(bucket_path, resource_.Budget()); +#if defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI + PlanningBudgetAccess::Reserve( + resource_.Budget(), + sizeof(DataSplitImpl) + + sizeof(std::_Sp_counted_ptr) + + native_path.size() + 1); +#endif + DataSplitImpl::Builder builder(BinaryRow::EmptyRow(), entry.bucket, native_path, + std::move(files)); + builder.WithSnapshot(snapshot_id) + .WithTotalBuckets(entry.total_buckets) + .RawConvertible(true); + PAIMON_ASSIGN_OR_RAISE(std::shared_ptr split, builder.Build()); + splits[i] = std::move(split); + } + PlanningBudgetAccess::Reserve(resource_.Budget(), 0); + std::shared_ptr plan = std::allocate_shared( + PlanningAllocator(resource_.Budget()), snapshot_id, std::move(splits)); + return plan; + } + std::unique_ptr context_; + PlanningResource resource_; + bool finished_ = false; + std::optional failure_; +}; + +} // namespace +#endif + +Result> TableScan::CreateWithPlanningBudget( + std::unique_ptr context, const std::shared_ptr& budget) { +#if !defined(__GLIBCXX__) || !_GLIBCXX_USE_CXX11_ABI || !defined(__linux__) || _GLIBCXX_RELEASE < 9 + return Status::NotImplemented( + "Bounded planning requires a qualified Linux libstdc++ 9+ C++11 ABI build"); +#else + if (!context || !budget) { + return Status::Invalid("Bounded planning requires context and budget"); + } + if (!context->GetSpecificTableSchema() || context->GetSpecificFileSystem() || + context->IsStreamingMode() || context->GetFormatTable() || context->GetCatalog() || + context->GetRealtimeContext() || context->GetGlobalIndexResult() || + context->GetIdentifier() || context->GetLimit()) { + return Status::NotImplemented("Unsupported bounded scan context"); + } + const auto& filter = context->GetScanFilters(); + if (filter && (filter->GetPredicate() || filter->GetBucketFilter() || + !filter->GetPartitionFilters().empty())) { + return Status::NotImplemented("Bounded planning does not admit planning filters"); + } + if (context->GetMemoryPool() != PlanningBudgetAccess::Upstream(budget)) { + return Status::Invalid("Planning budget and scan must use the same upstream allocator"); + } + try { + // Retaining caller-created input is charged before accepting the scan. + PlanningBudgetAccess::Reserve(budget, sizeof(ScanContext) + sizeof(BoundedTableScan)); + PlanningBudgetAccess::Reserve(budget, context->GetPath().capacity() + 1); + PlanningBudgetAccess::Reserve(budget, context->GetSpecificTableSchema()->capacity() + 1); + if (filter) { + PlanningBudgetAccess::Reserve( + budget, sizeof(std::_Sp_counted_ptr_inplace, + __gnu_cxx::_S_atomic>)); + } + using OptionNode = std::_Rb_tree_node>; + for (const auto& option : context->GetOptions()) { + PlanningBudgetAccess::Reserve(budget, sizeof(OptionNode)); + PlanningBudgetAccess::Reserve(budget, option.first.capacity() + 1); + PlanningBudgetAccess::Reserve(budget, option.second.capacity() + 1); + } + // Shared immutable SDK constants are initialized outside the per-query + // payload arena; their fixed process lifetime is documented separately. + (void)BinaryRow::EmptyRow(); + (void)SimpleStats::EmptyStats(); + return std::unique_ptr(new BoundedTableScan(std::move(context), budget)); + } catch (const std::bad_alloc&) { + PlanningBudgetAccess::MarkAllocationFailure(budget); + return PlanningBudgetAccess::Failure(budget); + } +#endif +} +} // namespace paimon diff --git a/src/paimon/core/table/source/bounded_table_scan_test.cpp b/src/paimon/core/table/source/bounded_table_scan_test.cpp new file mode 100644 index 000000000..1a8753039 --- /dev/null +++ b/src/paimon/core/table/source/bounded_table_scan_test.cpp @@ -0,0 +1,619 @@ +/* + * 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 + +#include +#include +#include +#include +#include + +#include "arrow/api.h" +#include "arrow/array/concatenate.h" +#include "arrow/c/bridge.h" +#include "gtest/gtest.h" +#include "paimon/common/memory/planning_budget_internal.h" +#include "paimon/core/table/source/data_split_impl.h" +#include "paimon/read_context.h" +#include "paimon/scan_context.h" +#include "paimon/table/source/table_read.h" +#include "paimon/table/source/table_scan.h" +#include "paimon/testing/utils/testharness.h" + +#ifdef PAIMON_TEST_PLANNING_ALLOCATIONS +namespace { +thread_local bool watching = false; +thread_local uint64_t requested = 0; +thread_local uint64_t ordinary_allocations = 0; +thread_local uint64_t fail_ordinary_at = UINT64_MAX; +thread_local bool observed_overdraft = false; +thread_local const paimon::PlanningBudget* observed_budget = nullptr; +void Observe(size_t bytes) { + if (watching) { + requested += bytes; + observed_overdraft |= requested > observed_budget->BytesAdmitted(); + } +} +} // namespace +extern "C" void* __real__Znwm(std::size_t size); +extern "C" void* __wrap__Znwm(std::size_t size) { + if (watching) { + if (ordinary_allocations++ == fail_ordinary_at) { + watching = false; + throw std::bad_alloc(); + } + Observe(size); + } + return __real__Znwm(size); +} +#endif + +namespace paimon::test { +namespace { +constexpr uint64_t kAmpleBudget = 32 * 1024 * 1024; + +std::string ReadText(const std::string& path) { + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} +void WriteText(const std::string& path, const std::string& text) { + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out.write(text.data(), text.size()); + ASSERT_TRUE(out.good()); +} +std::string Fixture(const std::string& format) { + return GetDataDir() + (format == "orc" + ? "/orc/append_types_compatibility.db/java_time_types" + : "/parquet/append_types_compatibility.db/rust_time_types"); +} +Result> Context( + const std::string& path, const std::shared_ptr& pool = GetDefaultPool(), + const std::map& options = {}, + const std::optional& schema_override = std::nullopt) { + ScanContextBuilder builder(path); + builder.SetTableSchema(schema_override.value_or(ReadText(path + "/schema/schema-0"))) + .WithMemoryPool(pool) + .AddOption("scan.snapshot-id", "1"); + for (const auto& [key, value] : options) { + builder.AddOption(key, value); + } + return builder.Finish(); +} +Result> BoundedPlan(const std::string& path, + const std::shared_ptr& budget) { + PAIMON_ASSIGN_OR_RAISE(auto context, Context(path, PlanningBudgetAccess::Upstream(budget))); + PAIMON_ASSIGN_OR_RAISE(auto scan, + TableScan::CreateWithPlanningBudget(std::move(context), budget)); + return scan->CreatePlan(); +} +Result> ReadSplits( + const std::string& path, const std::vector>& splits) { + ReadContextBuilder builder(path); + builder.SetTableSchema(ReadText(path + "/schema/schema-0")); + if (path.find("/orc/") != std::string::npos) { + // Existing ORC TIME decoding is unsupported. Exercise its native projected + // data-reader path without expanding this planning change into type fixes. + builder.SetReadFieldNames({"id"}); + } + PAIMON_ASSIGN_OR_RAISE(auto context, builder.Finish()); + PAIMON_ASSIGN_OR_RAISE(auto table, TableRead::Create(std::move(context))); + PAIMON_ASSIGN_OR_RAISE(auto reader, table->CreateReader(splits)); + std::vector> arrays; + while (true) { + PAIMON_ASSIGN_OR_RAISE(auto batch, reader->NextBatch()); + if (BatchReader::IsEofBatch(batch)) { + break; + } + auto imported = arrow::ImportArray(batch.first.get(), batch.second.get()); + if (!imported.ok()) { + reader->Close(); + return Status::Invalid("Cannot import planned data"); + } + arrays.push_back(std::move(imported).ValueOrDie()); + } + reader->Close(); + auto combined = arrow::Concatenate(arrays); + if (!combined.ok()) { + return Status::Invalid("Cannot concatenate planned data"); + } + return std::move(combined).ValueOrDie(); +} + +// Deterministic independent OCF input writer: production never calls this encoder. +void Long(int64_t value, std::string* out) { + uint64_t bits = (static_cast(value) << 1) ^ static_cast(value >> 63); + while (bits >= 128) { + out->push_back(static_cast((bits & 127) | 128)); + bits >>= 7; + } + out->push_back(static_cast(bits)); +} +void Bytes(std::string_view bytes, std::string* out) { + Long(bytes.size(), out); + out->append(bytes); +} +std::string Ocf(std::string_view schema, const std::string& records, int64_t count, + const std::string& codec = "null", bool unknown_size = false) { + const std::string sync = "0123456789abcdef"; + std::string out("Obj\1", 4); + Long(2, &out); + Bytes("avro.schema", &out); + Bytes(schema, &out); + Bytes("avro.codec", &out); + Bytes(codec, &out); + Long(0, &out); + out += sync; + if (count != 0) { + std::string block = records; + if (codec == "zstandard") { + block.resize(ZSTD_compressBound(records.size())); + auto* compressor = ZSTD_createCCtx(); + EXPECT_NE(compressor, nullptr); + EXPECT_FALSE(ZSTD_isError( + ZSTD_CCtx_setParameter(compressor, ZSTD_c_contentSizeFlag, unknown_size ? 0 : 1))); + size_t size = ZSTD_compress2(compressor, block.data(), block.size(), records.data(), + records.size()); + ZSTD_freeCCtx(compressor); + EXPECT_FALSE(ZSTD_isError(size)); + block.resize(size); + } + Long(count, &out); + Bytes(block, &out); + out += sync; + } + return out; +} +constexpr const char* kListSchema = + R"({"type":"record","name":"List","fields":[{"name":"_VERSION","type":"int"},{"name":"_FILE_NAME","type":"string"}]})"; +constexpr const char* kEntrySchema = + R"({"type":"record","name":"Entry","fields":[{"name":"_VERSION","type":"int"},{"name":"_KIND","type":"int"},{"name":"_PARTITION","type":"bytes"},{"name":"_BUCKET","type":"int"},{"name":"_TOTAL_BUCKETS","type":"int"},{"name":"_FILE","type":{"type":"record","name":"File","fields":[{"name":"_FILE_NAME","type":"string"},{"name":"_FILE_SIZE","type":"long"},{"name":"_ROW_COUNT","type":"long"},{"name":"_SCHEMA_ID","type":"long"},{"name":"_LEVEL","type":"int"},{"name":"_MIN_SEQUENCE_NUMBER","type":"long"},{"name":"_MAX_SEQUENCE_NUMBER","type":"long"}]}}]})"; +std::string Entry(int64_t kind = 0, std::string_view filename = "data.parquet") { + std::string row; + Long(2, &row); + Long(kind, &row); + Bytes(std::string(12, '\0'), &row); + Long(0, &row); + Long(-1, &row); + Bytes(filename, &row); + for (int64_t field : {10, 3, 0, 0, 0, 2}) { + Long(field, &row); + } + return row; +} +void Synthetic(const std::string& path, const std::string& records, int64_t count, + const std::string& codec = "null", bool unknown_size = false) { + WriteText( + path + "/snapshot/snapshot-1", + R"({"id":1,"schemaId":0,"baseManifestList":"bounded-base","deltaManifestList":"bounded-delta"})"); + WriteText(path + "/manifest/bounded-base", Ocf(kListSchema, "", 0)); + std::string list; + Long(2, &list); + Bytes("bounded-manifest", &list); + WriteText(path + "/manifest/bounded-delta", Ocf(kListSchema, list, 1)); + WriteText(path + "/manifest/bounded-manifest", + Ocf(kEntrySchema, records, count, codec, unknown_size)); +} + +class FaultPool : public MemoryPool { + public: + void* Malloc(uint64_t size, uint64_t alignment) override { + if (calls++ == fail_at) { + throw std::bad_alloc(); + } +#ifdef PAIMON_TEST_PLANNING_ALLOCATIONS + Observe(size); +#endif + void* p = upstream->Malloc(size, alignment); + live += size; + peak = std::max(live, peak); + return p; + } + void* Realloc(void*, size_t, size_t, uint64_t) override { + throw std::bad_alloc(); + } + void Free(void* p, uint64_t size) override { + upstream->Free(p, size); + live -= size; + } + uint64_t CurrentUsage() const override { + return live; + } + uint64_t MaxMemoryUsage() const override { + return peak; + } + std::shared_ptr upstream = GetDefaultPool(); + uint64_t calls = 0, live = 0, peak = 0; + uint64_t fail_at = UINT64_MAX; +}; +} // namespace + +TEST(PlanningBudgetTest, ExactAdmissionOverflowAndNonReplenishingFree) { + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(8)); + void* allocation = PlanningBudgetAccess::Allocate(budget, 8, 8); + PlanningBudgetAccess::Free(budget, allocation, 8, 8); + EXPECT_EQ(budget->BytesAdmitted(), 8); + EXPECT_THROW((void)PlanningBudgetAccess::Allocate(budget, 1, 8), std::bad_alloc); + EXPECT_THROW((void)PlanningBudgetAccess::Allocate(budget, 0, 8), std::bad_alloc); + EXPECT_TRUE(PlanningBudgetAccess::Failure(budget).IsCapacityError()); + ASSERT_OK_AND_ASSIGN(auto huge, PlanningBudget::Create(UINT64_MAX)); + PlanningBudgetAccess::Reserve(huge, UINT64_MAX); + EXPECT_THROW(PlanningBudgetAccess::Reserve(huge, 1), std::bad_alloc); + EXPECT_EQ(huge->BytesAdmitted(), UINT64_MAX); + EXPECT_FALSE(PlanningBudget::Create(1, nullptr).ok()); +} +TEST(PlanningBudgetTest, SharedConcurrentAdmissionCannotOversubscribe) { + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(1000)); + std::atomic accepted{0}; + std::vector workers; + for (int32_t i = 0; i < 8; ++i) { + workers.emplace_back([&] { + for (int32_t j = 0; j < 200; ++j) { + try { + PlanningBudgetAccess::Reserve(budget, 1); + ++accepted; + } catch (const std::bad_alloc&) { + break; + } + } + }); + } + for (auto& worker : workers) { + worker.join(); + } + EXPECT_EQ(accepted, 1000); + EXPECT_EQ(budget->BytesAdmitted(), 1000); +} + +#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && _GLIBCXX_RELEASE >= 9 +#ifdef PAIMON_TEST_PLANNING_ALLOCATIONS +TEST(BoundedTableScanTest, CalibrateStringAllocationObservationAndInjection) { + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); + PlanningBudgetAccess::Reserve(budget, 2049); + requested = 0; + ordinary_allocations = 0; + observed_overdraft = false; + observed_budget = budget.get(); + watching = true; + std::string value(2048, 'x'); + watching = false; + EXPECT_EQ(value.size(), 2048); + EXPECT_EQ(requested, 2049); + EXPECT_EQ(ordinary_allocations, 1); + ordinary_allocations = 0; + fail_ordinary_at = 0; + watching = true; + EXPECT_THROW((void)std::string(2048, 'x'), std::bad_alloc); + watching = false; + fail_ordinary_at = UINT64_MAX; +} + +TEST(BoundedTableScanTest, ObserveRealAllocationRequestsAndFailEveryPublicationAllocation) { + // Initialize fixed process-owned constants before observing query payload. + (void)BinaryRow::EmptyRow(); + (void)SimpleStats::EmptyStats(); + auto path = Fixture("parquet"); + auto pool = std::make_shared(); + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget, pool)); + ASSERT_OK_AND_ASSIGN(auto context, Context(path, pool)); + requested = 0; + ordinary_allocations = 0; + observed_overdraft = false; + observed_budget = budget.get(); + watching = true; + auto scan = TableScan::CreateWithPlanningBudget(std::move(context), budget); + // Retained input was constructed before observation. Consume all factory + // admission before planning so that those credits cannot hide a missing + // reservation in a later native publication constructor. + if (scan.ok()) { + requested = budget->BytesAdmitted(); + } + auto plan = + scan.ok() ? scan.value()->CreatePlan() : Result>(scan.status()); + watching = false; + ASSERT_OK(plan); + EXPECT_GT(requested, 0); + EXPECT_GT(ordinary_allocations, 0); + EXPECT_LE(requested, budget->BytesAdmitted()); + EXPECT_FALSE(observed_overdraft); + const uint64_t publications = ordinary_allocations; + std::move(plan).value().reset(); + std::move(scan).value().reset(); + EXPECT_EQ(pool->live, 0); + for (uint64_t allocation = 0; allocation < publications; ++allocation) { + SCOPED_TRACE(allocation); + auto faulty_pool = std::make_shared(); + ASSERT_OK_AND_ASSIGN(auto faulty_budget, PlanningBudget::Create(kAmpleBudget, faulty_pool)); + ASSERT_OK_AND_ASSIGN(auto faulty_context, Context(path, faulty_pool)); + requested = 0; + ordinary_allocations = 0; + fail_ordinary_at = allocation; + observed_budget = faulty_budget.get(); + watching = true; + auto faulty_scan = + TableScan::CreateWithPlanningBudget(std::move(faulty_context), faulty_budget); + auto failed = faulty_scan.ok() ? faulty_scan.value()->CreatePlan() + : Result>(faulty_scan.status()); + watching = false; + fail_ordinary_at = UINT64_MAX; + ASSERT_FALSE(failed.ok()); + EXPECT_TRUE(failed.status().IsOutOfMemory()); + EXPECT_EQ(faulty_pool->live, 0); + } +} +#endif + +TEST(BoundedTableScanTest, RealParquetAndOrcPlanReadParityAndEscapedSplit) { + for (const std::string format : {"parquet", "orc"}) { + SCOPED_TRACE(format); + auto path = Fixture(format); + ASSERT_OK_AND_ASSIGN(auto context, Context(path)); + ASSERT_OK_AND_ASSIGN(auto ordinary, TableScan::Create(std::move(context))); + ASSERT_OK_AND_ASSIGN(auto baseline, ordinary->CreatePlan()); + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); + ASSERT_OK_AND_ASSIGN(auto bounded, BoundedPlan(path, budget)); + ASSERT_EQ(bounded->SnapshotId(), baseline->SnapshotId()); + ASSERT_FALSE(bounded->Splits().empty()); + std::vector actual, expected; + for (const auto& split : bounded->Splits()) { + auto files = std::dynamic_pointer_cast(split)->GetFileList(); + actual.insert(actual.end(), files.begin(), files.end()); + } + for (const auto& split : baseline->Splits()) { + auto files = std::dynamic_pointer_cast(split)->GetFileList(); + expected.insert(expected.end(), files.begin(), files.end()); + } + ASSERT_EQ(actual.size(), expected.size()); + for (size_t i = 0; i < actual.size(); ++i) { + EXPECT_EQ(actual[i].file_path, expected[i].file_path); + EXPECT_EQ(actual[i].row_count, expected[i].row_count); + EXPECT_EQ(actual[i].file_size, expected[i].file_size); + EXPECT_EQ(actual[i].schema_id, expected[i].schema_id); + } + ASSERT_OK_AND_ASSIGN(auto actual_rows, ReadSplits(path, bounded->Splits())); + ASSERT_OK_AND_ASSIGN(auto expected_rows, ReadSplits(path, baseline->Splits())); + EXPECT_EQ(actual_rows->length(), format == "orc" ? 3 : 2); + EXPECT_TRUE(actual_rows->Equals(expected_rows)); + auto ids = std::static_pointer_cast( + std::static_pointer_cast(actual_rows)->GetFieldByName("id")); + ASSERT_TRUE(ids); + for (int64_t row = 0; row < ids->length(); ++row) { + EXPECT_FALSE(ids->IsNull(row)); + EXPECT_EQ(ids->Value(row), row + 1); + } + EXPECT_LE(budget->BytesAdmitted(), budget->ByteLimit()); + auto split = bounded->Splits().front(); + std::weak_ptr weak = budget; + bounded.reset(); + budget.reset(); + EXPECT_FALSE(weak.expired()); + ASSERT_OK_AND_ASSIGN(auto escaped_rows, ReadSplits(path, {split})); + EXPECT_TRUE(escaped_rows->Equals(expected_rows)); + split.reset(); + EXPECT_TRUE(weak.expired()); + } +} +TEST(BoundedTableScanTest, ExactSuccessfulLimitAndOneByteShortFailClosed) { + auto path = Fixture("parquet"); + ASSERT_OK_AND_ASSIGN(auto initial, PlanningBudget::Create(kAmpleBudget)); + ASSERT_OK(BoundedPlan(path, initial)); + auto needed = initial->BytesAdmitted(); + ASSERT_GT(needed, 0); + ASSERT_OK_AND_ASSIGN(auto exact, PlanningBudget::Create(needed)); + ASSERT_OK(BoundedPlan(path, exact)); + ASSERT_EQ(exact->BytesAdmitted(), needed); + ASSERT_OK_AND_ASSIGN(auto short_budget, PlanningBudget::Create(needed - 1)); + auto failed = BoundedPlan(path, short_budget); + ASSERT_FALSE(failed.ok()); + EXPECT_TRUE(failed.status().IsCapacityError()); + EXPECT_LE(short_budget->BytesAdmitted(), needed - 1); + auto retry = BoundedPlan(path, short_budget); + EXPECT_FALSE(retry.ok()); + ASSERT_OK_AND_ASSIGN(auto zero, PlanningBudget::Create(0)); + EXPECT_TRUE(BoundedPlan(path, zero).status().IsCapacityError()); +} +TEST(BoundedTableScanTest, AllocationFailureAtEveryArenaAllocationReleasesEverything) { + auto path = Fixture("parquet"); + auto successful_pool = std::make_shared(); + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget, successful_pool)); + { + ASSERT_OK_AND_ASSIGN(auto plan, BoundedPlan(path, budget)); + EXPECT_FALSE(plan->Splits().empty()); + } + auto allocation_count = successful_pool->calls; + EXPECT_EQ(successful_pool->live, 0); + ASSERT_GT(allocation_count, 10); + for (uint64_t allocation = 0; allocation < allocation_count; ++allocation) { + SCOPED_TRACE(allocation); + auto pool = std::make_shared(); + pool->fail_at = allocation; + ASSERT_OK_AND_ASSIGN(auto limited, PlanningBudget::Create(kAmpleBudget, pool)); + auto plan = BoundedPlan(path, limited); + ASSERT_FALSE(plan.ok()); + EXPECT_TRUE(plan.status().IsOutOfMemory()); + EXPECT_EQ(pool->live, 0); + EXPECT_LE(limited->BytesAdmitted(), limited->ByteLimit()); + } +} +TEST(BoundedTableScanTest, RefusesUnsupportedProfileAndIdentityRatherThanFallingBack) { + auto path = Fixture("parquet"); + for (const auto& options : + std::vector>{{{"bucket", "1"}}, + {{"branch", "other"}}, + {{"scan.mode", "latest"}}, + {{"scan.snapshot-id", "LATEST"}}, + {{"data-evolution.enabled", "true"}}, + {{"deletion-vectors.enabled", "true"}}, + {{"manifest.format", "orc"}}}) { + ASSERT_OK_AND_ASSIGN(auto context, Context(path, GetDefaultPool(), options)); + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); + ASSERT_OK_AND_ASSIGN(auto scan, + TableScan::CreateWithPlanningBudget(std::move(context), budget)); + auto first = scan->CreatePlan(); + ASSERT_FALSE(first.ok()); + auto second = scan->CreatePlan(); + ASSERT_FALSE(second.ok()); + EXPECT_EQ(first.status().ToString(), second.status().ToString()); + } + ASSERT_OK_AND_ASSIGN(auto context, Context(path)); + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget, GetMemoryPool())); + EXPECT_FALSE(TableScan::CreateWithPlanningBudget(std::move(context), budget).ok()); + auto directory = UniqueTestDirectory::Create(); + ASSERT_TRUE(directory); + ASSERT_TRUE(TestUtil::CopyDirectory(path, directory->Str())); + WriteText(directory->Str() + "/snapshot/snapshot-1", R"({"id":2,"schemaId":0})"); + ASSERT_OK_AND_ASSIGN(auto identity, PlanningBudget::Create(kAmpleBudget)); + EXPECT_FALSE(BoundedPlan(directory->Str(), identity).ok()); +} +TEST(BoundedTableScanTest, OcfNullZstdDuplicateDeleteAndLateCorruption) { + auto directory = UniqueTestDirectory::Create(); + ASSERT_TRUE(directory); + ASSERT_TRUE(TestUtil::CopyDirectory(Fixture("parquet"), directory->Str())); + auto path = directory->Str(); + for (const auto& [codec, unknown] : std::vector>{ + {"null", false}, {"zstandard", false}, {"zstandard", true}}) { + Synthetic(path, Entry(), 1, codec, unknown); + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); + ASSERT_OK_AND_ASSIGN(auto plan, BoundedPlan(path, budget)); + ASSERT_EQ(plan->Splits().size(), 1); + } + for (const auto& [records, count] : std::vector>{ + {Entry() + Entry(), 2}, {Entry(1), 1}, {Entry() + Entry().substr(0, 3), 2}}) { + Synthetic(path, records, count); + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); + EXPECT_FALSE(BoundedPlan(path, budget).ok()); + } + Synthetic(path, Entry(), 1); + auto valid = ReadText(path + "/manifest/bounded-manifest"); + for (size_t length : {size_t(0), size_t(4), valid.size() - 1}) { + WriteText(path + "/manifest/bounded-manifest", valid.substr(0, length)); + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); + EXPECT_FALSE(BoundedPlan(path, budget).ok()); + } + valid.back() ^= 1; + WriteText(path + "/manifest/bounded-manifest", valid); + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); + EXPECT_FALSE(BoundedPlan(path, budget).ok()); +} +TEST(BoundedTableScanTest, RejectMalformedWriterTypesAndDuplicateFields) { + auto directory = UniqueTestDirectory::Create(); + ASSERT_TRUE(directory); + ASSERT_TRUE(TestUtil::CopyDirectory(Fixture("parquet"), directory->Str())); + auto path = directory->Str(); + for (const std::string field : {"_DELETE_ROW_COUNT", "_EXTERNAL_PATH", "_FILE_SOURCE"}) { + for (const std::string type : {"float", "double", "boolean"}) { + SCOPED_TRACE(field + type); + Synthetic(path, Entry(), 1); + std::string schema = kEntrySchema; + auto position = schema.rfind("]}}"); + ASSERT_NE(position, std::string::npos); + schema.insert(position, ",{\"name\":\"" + field + "\",\"type\":\"" + type + "\"}"); + std::string row = Entry(); + row.append(type == "double" ? 8 : type == "float" ? 4 : 1, '\0'); + WriteText(path + "/manifest/bounded-manifest", Ocf(schema, row, 1)); + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); + EXPECT_FALSE(BoundedPlan(path, budget).ok()); + } + } + Synthetic(path, Entry(), 1); + std::string schema = kEntrySchema; + schema.insert(schema.rfind("]}}"), + "," + std::string(R"({"name":"_FILE_NAME","type":"string"})")); + std::string row = Entry(); + Bytes("other.parquet", &row); + WriteText(path + "/manifest/bounded-manifest", Ocf(schema, row, 1)); + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); + EXPECT_FALSE(BoundedPlan(path, budget).ok()); +} + +TEST(BoundedTableScanTest, RejectEmptyWrongTypesAndJsonAmbiguity) { + auto directory = UniqueTestDirectory::Create(); + ASSERT_TRUE(directory); + ASSERT_TRUE(TestUtil::CopyDirectory(Fixture("parquet"), directory->Str())); + auto path = directory->Str(); + for (const std::string type : + {R"({"type":"array","items":"string"})", R"({"type":"map","values":"string"})", + R"({"type":"fixed","name":"NotAPath","size":0})", R"("bytes")", R"("string")"}) { + Synthetic(path, Entry(), 1); + std::string schema = kEntrySchema; + schema.insert(schema.rfind("]}}"), ",{\"name\":\"_EXTERNAL_PATH\",\"type\":" + type + "}"); + std::string row = Entry(); + if (type.find("fixed") == std::string::npos) { + Long(0, &row); + } + WriteText(path + "/manifest/bounded-manifest", Ocf(schema, row, 1)); + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); + EXPECT_FALSE(BoundedPlan(path, budget).ok()); + } + Synthetic(path, Entry(), 1); + WriteText( + path + "/snapshot/snapshot-1", + R"({"id":1,"id":2,"schemaId":0,"baseManifestList":"bounded-base","deltaManifestList":"bounded-delta"})"); + ASSERT_OK_AND_ASSIGN(auto duplicate, PlanningBudget::Create(kAmpleBudget)); + EXPECT_FALSE(BoundedPlan(path, duplicate).ok()); + std::string huge_name(8 * 1024 * 1024, 'a'); + huge_name += ".parquet"; + Synthetic(path, Entry(0, huge_name), 1, "zstandard"); + ASSERT_OK_AND_ASSIGN(auto small, PlanningBudget::Create(4 * 1024 * 1024)); + auto exhausted = BoundedPlan(path, small); + ASSERT_FALSE(exhausted.ok()); + EXPECT_TRUE(exhausted.status().IsCapacityError()); + EXPECT_LE(small->BytesAdmitted(), small->ByteLimit()); +} + +TEST(BoundedTableScanTest, RejectNonRecordEmptyWriterAndFixedFileName) { + auto directory = UniqueTestDirectory::Create(); + ASSERT_TRUE(directory); + ASSERT_TRUE(TestUtil::CopyDirectory(Fixture("parquet"), directory->Str())); + const auto path = directory->Str(); + Synthetic(path, Entry(), 1); + std::string bad_root = kListSchema; + const auto root_type = bad_root.find("record"); + ASSERT_NE(root_type, std::string::npos); + bad_root.replace(root_type, 6, "int"); + WriteText(path + "/manifest/bounded-base", Ocf(bad_root, "", 0)); + ASSERT_OK_AND_ASSIGN(auto non_record, PlanningBudget::Create(kAmpleBudget)); + EXPECT_FALSE(BoundedPlan(path, non_record).ok()); + + Synthetic(path, Entry(), 1); + std::string schema = kEntrySchema; + const std::string original = R"({"name":"_FILE_NAME","type":"string"})"; + const auto field = schema.find(original); + ASSERT_NE(field, std::string::npos); + schema.replace(field, original.size(), + R"({"name":"_FILE_NAME","type":{"type":"fixed","name":"FixedName","size":12}})"); + std::string row = Entry(); + auto name = row.find("data.parquet"); + ASSERT_NE(name, std::string::npos); + ASSERT_GT(name, 0); + row.erase(name - 1, 1); // fixed has no string-length prefix. + WriteText(path + "/manifest/bounded-manifest", Ocf(schema, row, 1)); + ASSERT_OK_AND_ASSIGN(auto fixed_name, PlanningBudget::Create(kAmpleBudget)); + EXPECT_FALSE(BoundedPlan(path, fixed_name).ok()); +} + +#else +TEST(BoundedTableScanTest, UnqualifiedPlatformFailsExplicitly) { + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); + auto result = TableScan::CreateWithPlanningBudget(nullptr, budget); + EXPECT_FALSE(result.ok()); + EXPECT_TRUE(result.status().IsNotImplemented()); +} +#endif +} // namespace paimon::test From 8f03510b661c2a2a590752b30292aa17376dbc23 Mon Sep 17 00:00:00 2001 From: Aaron Wu Date: Sun, 25 Oct 2026 00:37:07 +0800 Subject: [PATCH 02/11] test(planning): keep unsupported ABI fixture helpers uninstantiated --- src/paimon/core/table/source/bounded_table_scan_test.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/paimon/core/table/source/bounded_table_scan_test.cpp b/src/paimon/core/table/source/bounded_table_scan_test.cpp index 1a8753039..86c26dcde 100644 --- a/src/paimon/core/table/source/bounded_table_scan_test.cpp +++ b/src/paimon/core/table/source/bounded_table_scan_test.cpp @@ -69,6 +69,7 @@ namespace paimon::test { namespace { constexpr uint64_t kAmpleBudget = 32 * 1024 * 1024; +#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && _GLIBCXX_RELEASE >= 9 std::string ReadText(const std::string& path) { std::ifstream input(path, std::ios::binary); return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; @@ -242,6 +243,7 @@ class FaultPool : public MemoryPool { uint64_t calls = 0, live = 0, peak = 0; uint64_t fail_at = UINT64_MAX; }; +#endif } // namespace TEST(PlanningBudgetTest, ExactAdmissionOverflowAndNonReplenishingFree) { From fdc81825f5f2599ae59143c2a0e53a0dafdb10dd Mon Sep 17 00:00:00 2001 From: Aaron Wu Date: Sun, 25 Oct 2026 22:19:26 +0800 Subject: [PATCH 03/11] fix(planning): qualify static decompression and allocator overlap --- docs/source/user_guide/bounded_planning.rst | 13 +- include/paimon/memory/planning_budget.h | 3 + src/paimon/CMakeLists.txt | 46 +++++++ .../core/table/source/bounded_table_scan.cpp | 12 +- .../table/source/bounded_table_scan_test.cpp | 119 ++++++++++++++++-- 5 files changed, 174 insertions(+), 19 deletions(-) diff --git a/docs/source/user_guide/bounded_planning.rst b/docs/source/user_guide/bounded_planning.rst index b19838682..47c63a87f 100644 --- a/docs/source/user_guide/bounded_planning.rst +++ b/docs/source/user_guide/bounded_planning.rst @@ -43,7 +43,11 @@ Zstandard frames with unknown decoded length grow output only after admission. Compressed and decoded storage coexist within the same budget. Create the budget once per query. Scans may share it; atomic admission never exceeds -the shared limit. The first allocation failure makes the budget terminal. +the shared limit. Admission does not serialize upstream ``Malloc``/``Free`` calls. +Overlapping scans or destruction of escaped plans/splits require a thread-safe +upstream, such as ``GetDefaultPool()``, or caller synchronization covering both +operations and cleanup. Individual scan objects must not be called concurrently. +The first allocation failure makes the budget terminal. Exhaustion returns ``CapacityError``; upstream allocation failure returns ``OutOfMemory``. Malformed inputs return an error and unsupported profiles return ``NotImplemented``. No failure returns a partial plan or invokes the unrestricted @@ -68,8 +72,11 @@ still counts in ``BytesAdmitted()``. Admitted profile ---------------- -The first implementation supports Linux with libstdc++ 9 or later and its C++11 string ABI. Other -platforms return ``NotImplemented``. It supports local filesystem tables with: +The first implementation supports Linux with libstdc++ 9 or later, its C++11 +string ABI, and statically linked Zstandard with matching headers and static +workspace APIs (including the default bundled Zstandard). Other builds return +``NotImplemented`` from the bounded factory and retain ordinary scan support. +It supports local filesystem tables with: * An explicitly retained schema and positive numeric ``scan.snapshot-id``. * Unpartitioned append-only data, ``bucket=-1``, main branch and matching schema ID. diff --git a/include/paimon/memory/planning_budget.h b/include/paimon/memory/planning_budget.h index cc9dbc164..071110c2b 100644 --- a/include/paimon/memory/planning_budget.h +++ b/include/paimon/memory/planning_budget.h @@ -33,6 +33,9 @@ class PlanningBudgetAccess; /// Query-owned cumulative allocation admission for bounded metadata planning. /// Frees do not replenish admission: this deliberately also bounds temporary peaks. /// The limit covers requested storage, not allocator overhead or process RSS. +/// Admission is atomic, but upstream Malloc/Free calls are not serialized. Overlapping +/// scans or destruction of escaped plans/splits require a thread-safe upstream (such +/// as GetDefaultPool()), or caller synchronization covering operations and cleanup. class PAIMON_EXPORT PlanningBudget { public: static Result> Create( diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt index 73f29c9bd..0c435e4bf 100644 --- a/src/paimon/CMakeLists.txt +++ b/src/paimon/CMakeLists.txt @@ -522,6 +522,44 @@ if(PAIMON_ENABLE_REST) rest/rest_util.cpp) endif() +# The bounded decoder uses Zstd's static-only workspace API. Ordinary scans also +# support shared Zstd, so disable only the bounded entry point for that build. +set(PAIMON_BOUNDED_PLANNING_ZSTD OFF) +set(_bounded_zstd_target zstd) +get_target_property(_bounded_zstd_type ${_bounded_zstd_target} TYPE) +if(_bounded_zstd_type STREQUAL "INTERFACE_LIBRARY") + get_target_property(_bounded_zstd_links zstd INTERFACE_LINK_LIBRARIES) + list(LENGTH _bounded_zstd_links _bounded_zstd_link_count) + if(_bounded_zstd_link_count EQUAL 1 AND TARGET "${_bounded_zstd_links}") + set(_bounded_zstd_target ${_bounded_zstd_links}) + get_target_property(_bounded_zstd_type ${_bounded_zstd_target} TYPE) + endif() +endif() +get_target_property(_bounded_zstd_location ${_bounded_zstd_target} IMPORTED_LOCATION) +if(_bounded_zstd_type STREQUAL "STATIC_LIBRARY" + OR (_bounded_zstd_type STREQUAL "UNKNOWN_LIBRARY" AND _bounded_zstd_location MATCHES + "\\.a$")) + if(TARGET zstd_ep) + # The pinned bundled static library is built after configuration. + set(PAIMON_BOUNDED_PLANNING_ZSTD ON) + else() + include(CMakePushCheckState) + include(CheckCXXSourceCompiles) + cmake_push_check_state(RESET) + set(CMAKE_REQUIRED_LIBRARIES zstd) + check_cxx_source_compiles("#define ZSTD_STATIC_LINKING_ONLY +#include +int main() { + auto volatile estimate = &ZSTD_estimateDStreamSize_fromFrame; + auto volatile initialize = &ZSTD_initStaticDStream; + return !estimate || !initialize; +}" + PAIMON_BOUNDED_ZSTD_STATIC_API) + cmake_pop_check_state() + set(PAIMON_BOUNDED_PLANNING_ZSTD ${PAIMON_BOUNDED_ZSTD_STATIC_API}) + endif() +endif() + add_paimon_lib(paimon SOURCES ${PAIMON_COMMON_SRCS} @@ -554,6 +592,11 @@ add_paimon_lib(paimon ${PAIMON_REST_LINK_LIBS} SHARED_LINK_FLAGS ${PAIMON_VERSION_SCRIPT_FLAGS}) +set_property(SOURCE core/table/source/bounded_table_scan.cpp + APPEND + PROPERTY COMPILE_DEFINITIONS + PAIMON_BOUNDED_PLANNING_ZSTD=$ +) add_subdirectory(common/file_index) add_subdirectory(common/global_index) @@ -590,6 +633,9 @@ if(PAIMON_BUILD_TESTS) ${PAIMON_ORC_FILE_FORMAT_STATIC_LINK_LIBS} ${PAIMON_AVRO_FILE_FORMAT_STATIC_LINK_LIBS} ${GTEST_LINK_TOOLCHAIN}) + target_compile_definitions(paimon-bounded-table-scan-test + PRIVATE PAIMON_BOUNDED_PLANNING_ZSTD=$ + ) include(CheckCXXSourceCompiles) check_cxx_source_compiles("#include #if !defined(__GLIBCXX__) || !_GLIBCXX_USE_CXX11_ABI || _GLIBCXX_RELEASE < 9 diff --git a/src/paimon/core/table/source/bounded_table_scan.cpp b/src/paimon/core/table/source/bounded_table_scan.cpp index 855851f9c..5568e09b8 100644 --- a/src/paimon/core/table/source/bounded_table_scan.cpp +++ b/src/paimon/core/table/source/bounded_table_scan.cpp @@ -51,7 +51,8 @@ #include "rapidjson/document.h" namespace paimon { -#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && _GLIBCXX_RELEASE >= 9 +#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && \ + _GLIBCXX_RELEASE >= 9 && PAIMON_BOUNDED_PLANNING_ZSTD namespace { constexpr size_t kMaximumDepth = 64; @@ -1136,10 +1137,15 @@ class BoundedTableScan : public TableScan { Result> TableScan::CreateWithPlanningBudget( std::unique_ptr context, const std::shared_ptr& budget) { -#if !defined(__GLIBCXX__) || !_GLIBCXX_USE_CXX11_ABI || !defined(__linux__) || _GLIBCXX_RELEASE < 9 +#if !defined(__GLIBCXX__) || !_GLIBCXX_USE_CXX11_ABI || !defined(__linux__) || \ + _GLIBCXX_RELEASE < 9 || !PAIMON_BOUNDED_PLANNING_ZSTD return Status::NotImplemented( - "Bounded planning requires a qualified Linux libstdc++ 9+ C++11 ABI build"); + "Bounded planning requires Linux libstdc++ 9+ C++11 ABI and compatible static Zstd"); #else + if (ZSTD_versionNumber() != ZSTD_VERSION_NUMBER) { + return Status::NotImplemented( + "Bounded planning requires matching Zstd headers and library"); + } if (!context || !budget) { return Status::Invalid("Bounded planning requires context and budget"); } diff --git a/src/paimon/core/table/source/bounded_table_scan_test.cpp b/src/paimon/core/table/source/bounded_table_scan_test.cpp index 86c26dcde..61577de5f 100644 --- a/src/paimon/core/table/source/bounded_table_scan_test.cpp +++ b/src/paimon/core/table/source/bounded_table_scan_test.cpp @@ -20,9 +20,12 @@ #include #include +#include +#include #include #include #include +#include #include #include "arrow/api.h" @@ -69,7 +72,8 @@ namespace paimon::test { namespace { constexpr uint64_t kAmpleBudget = 32 * 1024 * 1024; -#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && _GLIBCXX_RELEASE >= 9 +#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && \ + _GLIBCXX_RELEASE >= 9 && PAIMON_BOUNDED_PLANNING_ZSTD std::string ReadText(const std::string& path) { std::ifstream input(path, std::ios::binary); return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; @@ -99,8 +103,9 @@ Result> Context( } Result> BoundedPlan(const std::string& path, const std::shared_ptr& budget) { - PAIMON_ASSIGN_OR_RAISE(auto context, Context(path, PlanningBudgetAccess::Upstream(budget))); - PAIMON_ASSIGN_OR_RAISE(auto scan, + PAIMON_ASSIGN_OR_RAISE(std::unique_ptr context, + Context(path, PlanningBudgetAccess::Upstream(budget))); + PAIMON_ASSIGN_OR_RAISE(std::unique_ptr scan, TableScan::CreateWithPlanningBudget(std::move(context), budget)); return scan->CreatePlan(); } @@ -113,12 +118,12 @@ Result> ReadSplits( // data-reader path without expanding this planning change into type fixes. builder.SetReadFieldNames({"id"}); } - PAIMON_ASSIGN_OR_RAISE(auto context, builder.Finish()); - PAIMON_ASSIGN_OR_RAISE(auto table, TableRead::Create(std::move(context))); - PAIMON_ASSIGN_OR_RAISE(auto reader, table->CreateReader(splits)); + PAIMON_ASSIGN_OR_RAISE(std::unique_ptr context, builder.Finish()); + PAIMON_ASSIGN_OR_RAISE(std::unique_ptr table, TableRead::Create(std::move(context))); + PAIMON_ASSIGN_OR_RAISE(std::unique_ptr reader, table->CreateReader(splits)); std::vector> arrays; while (true) { - PAIMON_ASSIGN_OR_RAISE(auto batch, reader->NextBatch()); + PAIMON_ASSIGN_OR_RAISE(BatchReader::ReadBatch batch, reader->NextBatch()); if (BatchReader::IsEofBatch(batch)) { break; } @@ -283,7 +288,81 @@ TEST(PlanningBudgetTest, SharedConcurrentAdmissionCannotOversubscribe) { EXPECT_EQ(budget->BytesAdmitted(), 1000); } -#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && _GLIBCXX_RELEASE >= 9 +TEST(PlanningBudgetTest, SharedAllocationAndCrossThreadFreeMayOverlap) { + class RendezvousPool : public MemoryPool { + public: + void* Malloc(uint64_t size, uint64_t alignment) override { + Rendezvous(); + return upstream->Malloc(size, alignment); + } + void Free(void* p, uint64_t size) override { + Rendezvous(); + upstream->Free(p, size); + } + void* Realloc(void* p, size_t old_size, size_t new_size, uint64_t alignment) override { + return upstream->Realloc(p, old_size, new_size, alignment); + } + uint64_t CurrentUsage() const override { + return upstream->CurrentUsage(); + } + uint64_t MaxMemoryUsage() const override { + return upstream->MaxMemoryUsage(); + } + void Rendezvous() { + if (!overlap) { + return; + } + std::unique_lock lock(mutex); + ++arrivals; + ready.notify_all(); + timed_out |= + !ready.wait_for(lock, std::chrono::seconds(5), [&] { return arrivals == 2; }); + } + // Same implementation as GetDefaultPool(), with isolated usage counters. + std::unique_ptr upstream = GetMemoryPool(); + bool overlap = false, timed_out = false; + uint32_t arrivals = 0; + std::mutex mutex; + std::condition_variable ready; + }; + auto pool = std::make_shared(); + constexpr uint64_t kBytes = 8; + constexpr uint64_t kIterations = 1000; + constexpr uint64_t kWorkers = 8; + constexpr uint64_t kLimit = (kWorkers * kIterations + 2) * kBytes; + ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kLimit, pool)); + void* escaped = PlanningBudgetAccess::Allocate(budget, kBytes, kBytes); + void* allocated = nullptr; + pool->overlap = true; + std::thread allocating( + [&] { allocated = PlanningBudgetAccess::Allocate(budget, kBytes, kBytes); }); + std::thread freeing([&] { PlanningBudgetAccess::Free(budget, escaped, kBytes, kBytes); }); + allocating.join(); + freeing.join(); + pool->overlap = false; + EXPECT_FALSE(pool->timed_out); + EXPECT_EQ(pool->arrivals, 2); + PlanningBudgetAccess::Free(budget, allocated, kBytes, kBytes); + + std::vector workers; + for (uint64_t i = 0; i < kWorkers; ++i) { + workers.emplace_back([&] { + for (uint64_t j = 0; j < kIterations; ++j) { + void* p = PlanningBudgetAccess::Allocate(budget, kBytes, kBytes); + PlanningBudgetAccess::Free(budget, p, kBytes, kBytes); + } + }); + } + for (auto& worker : workers) { + worker.join(); + } + EXPECT_EQ(budget->BytesAdmitted(), kLimit); + EXPECT_EQ(pool->CurrentUsage(), 0); + EXPECT_TRUE(PlanningBudgetAccess::Failure(budget).ok()); +} + +#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && \ + _GLIBCXX_RELEASE >= 9 && PAIMON_BOUNDED_PLANNING_ZSTD #ifdef PAIMON_TEST_PLANNING_ALLOCATIONS TEST(BoundedTableScanTest, CalibrateStringAllocationObservationAndInjection) { ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); @@ -313,7 +392,7 @@ TEST(BoundedTableScanTest, ObserveRealAllocationRequestsAndFailEveryPublicationA auto path = Fixture("parquet"); auto pool = std::make_shared(); ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget, pool)); - ASSERT_OK_AND_ASSIGN(auto context, Context(path, pool)); + ASSERT_OK_AND_ASSIGN(std::unique_ptr context, Context(path, pool)); requested = 0; ordinary_allocations = 0; observed_overdraft = false; @@ -365,7 +444,7 @@ TEST(BoundedTableScanTest, RealParquetAndOrcPlanReadParityAndEscapedSplit) { for (const std::string format : {"parquet", "orc"}) { SCOPED_TRACE(format); auto path = Fixture(format); - ASSERT_OK_AND_ASSIGN(auto context, Context(path)); + ASSERT_OK_AND_ASSIGN(std::unique_ptr context, Context(path)); ASSERT_OK_AND_ASSIGN(auto ordinary, TableScan::Create(std::move(context))); ASSERT_OK_AND_ASSIGN(auto baseline, ordinary->CreatePlan()); ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); @@ -463,9 +542,10 @@ TEST(BoundedTableScanTest, RefusesUnsupportedProfileAndIdentityRatherThanFalling {{"data-evolution.enabled", "true"}}, {{"deletion-vectors.enabled", "true"}}, {{"manifest.format", "orc"}}}) { - ASSERT_OK_AND_ASSIGN(auto context, Context(path, GetDefaultPool(), options)); + ASSERT_OK_AND_ASSIGN(std::unique_ptr context, + Context(path, GetDefaultPool(), options)); ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); - ASSERT_OK_AND_ASSIGN(auto scan, + ASSERT_OK_AND_ASSIGN(std::unique_ptr scan, TableScan::CreateWithPlanningBudget(std::move(context), budget)); auto first = scan->CreatePlan(); ASSERT_FALSE(first.ok()); @@ -473,7 +553,7 @@ TEST(BoundedTableScanTest, RefusesUnsupportedProfileAndIdentityRatherThanFalling ASSERT_FALSE(second.ok()); EXPECT_EQ(first.status().ToString(), second.status().ToString()); } - ASSERT_OK_AND_ASSIGN(auto context, Context(path)); + ASSERT_OK_AND_ASSIGN(std::unique_ptr context, Context(path)); ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget, GetMemoryPool())); EXPECT_FALSE(TableScan::CreateWithPlanningBudget(std::move(context), budget).ok()); auto directory = UniqueTestDirectory::Create(); @@ -616,6 +696,19 @@ TEST(BoundedTableScanTest, UnqualifiedPlatformFailsExplicitly) { auto result = TableScan::CreateWithPlanningBudget(nullptr, budget); EXPECT_FALSE(result.ok()); EXPECT_TRUE(result.status().IsNotImplemented()); + + const std::string path = + GetDataDir() + "/parquet/append_types_compatibility.db/rust_time_types"; + std::ifstream input(path + "/schema/schema-0", std::ios::binary); + ASSERT_TRUE(input.good()); + const std::string schema{std::istreambuf_iterator(input), + std::istreambuf_iterator()}; + ScanContextBuilder builder(path); + builder.SetTableSchema(schema).AddOption("scan.snapshot-id", "1"); + ASSERT_OK_AND_ASSIGN(auto context, builder.Finish()); + ASSERT_OK_AND_ASSIGN(auto ordinary, TableScan::Create(std::move(context))); + ASSERT_OK_AND_ASSIGN(auto plan, ordinary->CreatePlan()); + EXPECT_FALSE(plan->Splits().empty()); } #endif } // namespace paimon::test From c5bbc10c50476d61596664b50478ae333a0bf6dc Mon Sep 17 00:00:00 2001 From: Aaron Wu Date: Sun, 25 Oct 2026 22:20:52 +0800 Subject: [PATCH 04/11] fix(planning): refresh system capability checks on reconfigure --- src/paimon/CMakeLists.txt | 2 ++ src/paimon/core/table/source/bounded_table_scan_test.cpp | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt index 0c435e4bf..ca5c05edb 100644 --- a/src/paimon/CMakeLists.txt +++ b/src/paimon/CMakeLists.txt @@ -547,6 +547,8 @@ if(_bounded_zstd_type STREQUAL "STATIC_LIBRARY" include(CheckCXXSourceCompiles) cmake_push_check_state(RESET) set(CMAKE_REQUIRED_LIBRARIES zstd) + # Reconfiguration may select a different system library in the same build. + unset(PAIMON_BOUNDED_ZSTD_STATIC_API CACHE) check_cxx_source_compiles("#define ZSTD_STATIC_LINKING_ONLY #include int main() { diff --git a/src/paimon/core/table/source/bounded_table_scan_test.cpp b/src/paimon/core/table/source/bounded_table_scan_test.cpp index 61577de5f..f77761968 100644 --- a/src/paimon/core/table/source/bounded_table_scan_test.cpp +++ b/src/paimon/core/table/source/bounded_table_scan_test.cpp @@ -358,7 +358,7 @@ TEST(PlanningBudgetTest, SharedAllocationAndCrossThreadFreeMayOverlap) { } EXPECT_EQ(budget->BytesAdmitted(), kLimit); EXPECT_EQ(pool->CurrentUsage(), 0); - EXPECT_TRUE(PlanningBudgetAccess::Failure(budget).ok()); + EXPECT_NO_THROW(PlanningBudgetAccess::Reserve(budget, 0)); } #if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && \ From f6acf07c3597fe56377d10d275f837d817138e5a Mon Sep 17 00:00:00 2001 From: Aaron Wu Date: Sun, 25 Oct 2026 22:48:02 +0800 Subject: [PATCH 05/11] style(planning): satisfy allocator and fixture lint checks --- src/paimon/common/memory/planning_budget_internal.h | 2 +- src/paimon/core/table/source/bounded_table_scan.cpp | 2 +- src/paimon/core/table/source/bounded_table_scan_test.cpp | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/paimon/common/memory/planning_budget_internal.h b/src/paimon/common/memory/planning_budget_internal.h index c31b1a15c..1fda3881f 100644 --- a/src/paimon/common/memory/planning_budget_internal.h +++ b/src/paimon/common/memory/planning_budget_internal.h @@ -119,7 +119,7 @@ class PlanningAllocator { explicit PlanningAllocator(std::shared_ptr budget) : budget(std::move(budget)) {} template - PlanningAllocator(const PlanningAllocator& other) : budget(other.budget) {} + explicit PlanningAllocator(const PlanningAllocator& other) : budget(other.budget) {} T* allocate(size_t count) { if (count > std::numeric_limits::max() / sizeof(T)) { throw std::bad_alloc(); diff --git a/src/paimon/core/table/source/bounded_table_scan.cpp b/src/paimon/core/table/source/bounded_table_scan.cpp index 5568e09b8..319478997 100644 --- a/src/paimon/core/table/source/bounded_table_scan.cpp +++ b/src/paimon/core/table/source/bounded_table_scan.cpp @@ -294,7 +294,7 @@ class Cursor { uint64_t bits = 0; for (uint32_t shift = 0; shift < 70; shift += 7) { PAIMON_ASSIGN_OR_RAISE(std::string_view byte, Take(1)); - uint8_t value = static_cast(byte[0]); + auto value = static_cast(byte[0]); if (shift == 63 && value > 1) { return Status::Invalid("Avro integer overflow"); } diff --git a/src/paimon/core/table/source/bounded_table_scan_test.cpp b/src/paimon/core/table/source/bounded_table_scan_test.cpp index f77761968..55c861162 100644 --- a/src/paimon/core/table/source/bounded_table_scan_test.cpp +++ b/src/paimon/core/table/source/bounded_table_scan_test.cpp @@ -583,7 +583,7 @@ TEST(BoundedTableScanTest, OcfNullZstdDuplicateDeleteAndLateCorruption) { } Synthetic(path, Entry(), 1); auto valid = ReadText(path + "/manifest/bounded-manifest"); - for (size_t length : {size_t(0), size_t(4), valid.size() - 1}) { + for (size_t length : {static_cast(0), static_cast(4), valid.size() - 1}) { WriteText(path + "/manifest/bounded-manifest", valid.substr(0, length)); ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); EXPECT_FALSE(BoundedPlan(path, budget).ok()); @@ -605,7 +605,7 @@ TEST(BoundedTableScanTest, RejectMalformedWriterTypesAndDuplicateFields) { std::string schema = kEntrySchema; auto position = schema.rfind("]}}"); ASSERT_NE(position, std::string::npos); - schema.insert(position, ",{\"name\":\"" + field + "\",\"type\":\"" + type + "\"}"); + schema.insert(position, R"(,{"name":")" + field + R"(","type":")" + type + R"("})"); std::string row = Entry(); row.append(type == "double" ? 8 : type == "float" ? 4 : 1, '\0'); WriteText(path + "/manifest/bounded-manifest", Ocf(schema, row, 1)); @@ -634,7 +634,7 @@ TEST(BoundedTableScanTest, RejectEmptyWrongTypesAndJsonAmbiguity) { R"({"type":"fixed","name":"NotAPath","size":0})", R"("bytes")", R"("string")"}) { Synthetic(path, Entry(), 1); std::string schema = kEntrySchema; - schema.insert(schema.rfind("]}}"), ",{\"name\":\"_EXTERNAL_PATH\",\"type\":" + type + "}"); + schema.insert(schema.rfind("]}}"), R"(,{"name":"_EXTERNAL_PATH","type":)" + type + "}"); std::string row = Entry(); if (type.find("fixed") == std::string::npos) { Long(0, &row); From 3ad0ed736ea793ec8a44598ef1c1d471e4581b10 Mon Sep 17 00:00:00 2001 From: Aaron Wu Date: Mon, 2 Nov 2026 21:46:31 +0800 Subject: [PATCH 06/11] fix(planning): drain accepted metadata tasks before unwinding --- docs/source/user_guide.rst | 1 - docs/source/user_guide/bounded_planning.rst | 158 --- docs/source/user_guide/read.rst | 41 + include/paimon/memory/planning_budget.h | 60 - include/paimon/scan_context.h | 3 + include/paimon/table/source/table_scan.h | 8 - src/paimon/CMakeLists.txt | 88 +- src/paimon/common/memory/planning_budget.cpp | 35 - .../common/memory/planning_budget_internal.h | 143 -- src/paimon/core/operation/file_store_scan.cpp | 17 + .../core/table/source/bounded_table_scan.cpp | 1193 ----------------- .../table/source/bounded_table_scan_test.cpp | 714 ---------- .../source/planning_memory_pool_test.cpp | 402 ++++++ 13 files changed, 464 insertions(+), 2399 deletions(-) delete mode 100644 docs/source/user_guide/bounded_planning.rst delete mode 100644 include/paimon/memory/planning_budget.h delete mode 100644 src/paimon/common/memory/planning_budget.cpp delete mode 100644 src/paimon/common/memory/planning_budget_internal.h delete mode 100644 src/paimon/core/table/source/bounded_table_scan.cpp delete mode 100644 src/paimon/core/table/source/bounded_table_scan_test.cpp create mode 100644 src/paimon/core/table/source/planning_memory_pool_test.cpp diff --git a/docs/source/user_guide.rst b/docs/source/user_guide.rst index f59e982e0..a8d3c7f8d 100644 --- a/docs/source/user_guide.rst +++ b/docs/source/user_guide.rst @@ -39,7 +39,6 @@ User Guide user_guide/commit user_guide/compaction user_guide/read - user_guide/bounded_planning user_guide/metrics user_guide/clean user_guide/prefetch diff --git a/docs/source/user_guide/bounded_planning.rst b/docs/source/user_guide/bounded_planning.rst deleted file mode 100644 index 47c63a87f..000000000 --- a/docs/source/user_guide/bounded_planning.rst +++ /dev/null @@ -1,158 +0,0 @@ -.. 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. - -Bounded metadata planning -========================= - -``TableScan::CreateWithPlanningBudget`` opts into synchronous, finite-byte metadata -planning. ``TableScan::Create`` retains its existing behavior. This API admits -allocations before requesting storage, including allocations outside ``MemoryPool``. -It does not plan first and compare a memory estimate afterwards. - -Budget contract ---------------- - -A ``PlanningBudget`` holds a cumulative requested-byte limit. Every admitted -allocation consumes bytes permanently; freeing storage does not replenish the -budget. Consequently temporary input, parser, decompression and output storage -cannot each reuse the whole limit. This conservative policy bounds their combined -peak and the total admitted allocation work; it can reject a query whose peak live -storage would fit. It measures requested storage, including allocator bookkeeping -added by this planner, rather than process RSS, malloc metadata, alignment padding, -stack storage or filesystem page cache. - -The budget covers retained caller-provided path/schema/options, scan and plan -objects, metadata file buffers, RapidJSON DOM and parser stacks, Avro schema lookup -and decoded records, Zstandard stream workspace and output, duplicate detection, -file metadata, split vectors, strings and shared ownership control blocks. -Zstandard frames with unknown decoded length grow output only after admission. -Compressed and decoded storage coexist within the same budget. - -Create the budget once per query. Scans may share it; atomic admission never exceeds -the shared limit. Admission does not serialize upstream ``Malloc``/``Free`` calls. -Overlapping scans or destruction of escaped plans/splits require a thread-safe -upstream, such as ``GetDefaultPool()``, or caller synchronization covering both -operations and cleanup. Individual scan objects must not be called concurrently. -The first allocation failure makes the budget terminal. -Exhaustion returns ``CapacityError``; upstream allocation failure returns -``OutOfMemory``. Malformed inputs return an error and unsupported profiles return -``NotImplemented``. No failure returns a partial plan or invokes the unrestricted -planner. A failed scan returns its original failure on subsequent calls; a -successful batch scan produces one plan and then returns end of scan. - -Callers create and validate the ``ScanContext`` and retained schema before invoking -this API. Their construction, the fixed budget control object, fixed error Status -messages, and process-owned immutable empty row/statistics constants are outside -the variable planning payload. Existing shared executor, allocator and cache -services supplied through the context are also outside it; this planner schedules -no work on them and reads/writes no shared metadata cache. Creating or growing -those services is not covered. Data reading, metrics retrieval, split serialization -and user copies made after planning require their own allocation policy. - -Successful plans and separately retained nonempty splits retain the budget and its -upstream allocator through file-metadata shared owners. Destroying the scan or plan -therefore cannot invalidate an escaped split. Failed planning releases all temporary -storage, including JSON children whose parent allocation failed. Freed storage -still counts in ``BytesAdmitted()``. - -Admitted profile ----------------- - -The first implementation supports Linux with libstdc++ 9 or later, its C++11 -string ABI, and statically linked Zstandard with matching headers and static -workspace APIs (including the default bundled Zstandard). Other builds return -``NotImplemented`` from the bounded factory and retain ordinary scan support. -It supports local filesystem tables with: - -* An explicitly retained schema and positive numeric ``scan.snapshot-id``. -* Unpartitioned append-only data, ``bucket=-1``, main branch and matching schema ID. -* Avro OCF manifest lists and version-2 ADD-only entries, null or Zstandard codec. -* Parquet or ORC data files without external paths, extra files, embedded indexes, - deletion vectors or data-evolution metadata. -* No streaming, planning predicates, partition/bucket filters, limit, realtime - context, catalog/format table or global-index result. - -DELETE entries, duplicate ADD identities, different file schemas, unsupported scan -selectors and unsupported codecs fail explicitly. Missing/expired snapshots fail. -Snapshot and schema IDs must match the bound request. Metadata names cannot escape -one filesystem component. Inputs must remain available and immutable throughout -planning, as with a retained snapshot read; this API does not pin snapshot retention -or provide an atomic filesystem snapshot. - -Planning emits one ordinary native split per file and omits pruning statistics. -The normal native data reader consumes these splits. This profile deliberately -avoids generic Avro/Arrow decoding, schema discovery, asynchronous planning and -shared caches, because those paths do not route all variable allocations through -an admission allocator. Enabling this opt-in control can change planning latency, -I/O and split grouping; it is not a performance-equivalent switch for the cached -unrestricted planner. It does not change normal scans. - -Allocation qualification ------------------------- - -Parser and decompressor buffers use a PMR resource backed by the supplied budget. -Shared metadata and plan blocks use ``allocate_shared`` with that allocator. -Ordinary SDK publication constructors still require their existing STL types. -Before invoking them the planner reserves GNU C++11 ABI string forward-range/copy -storage (at most length + 1), exact-size vector element storage, the split object -and its GNU shared-pointer control block. Small-string optimization may cause -conservative over-admission. No approximate per-row multiplier is used. - -This qualification relies on the GNU implementations of ``basic_string::_M_construct``, -``vector(size_type)`` and ``shared_ptr(Y*)``, not on a portable standard guarantee -about capacities. The Linux static test target links libstdc++ statically so that its out-of-line -string constructors are included in the ``operator new`` hook. A long-string -calibration proves observation and failure injection. The test observes upstream -pool requests during the complete public factory and planning call, and consumes -all retained-input admission before observing publication allocations so those -credits cannot mask a missing reservation. A new standard-library implementation or ABI needs requalification -before expanding this support gate. - -Usage ------ - -.. code-block:: cpp - - #include "paimon/memory/planning_budget.h" - #include "paimon/scan_context.h" - #include "paimon/table/source/table_scan.h" - - // schema_json is retained, validated table schema JSON bound by the caller. - auto pool = paimon::GetDefaultPool(); - PAIMON_ASSIGN_OR_RAISE(auto budget, - paimon::PlanningBudget::Create(32 * 1024 * 1024, pool)); - paimon::ScanContextBuilder builder(local_table_path); - builder.SetTableSchema(schema_json) - .WithMemoryPool(pool) - .AddOption("scan.snapshot-id", "1"); - PAIMON_ASSIGN_OR_RAISE(auto context, builder.Finish()); - PAIMON_ASSIGN_OR_RAISE(auto scan, - paimon::TableScan::CreateWithPlanningBudget( - std::move(context), budget)); - PAIMON_ASSIGN_OR_RAISE(auto plan, scan->CreatePlan()); - // Feed plan->Splits() to the normal TableRead. Data-reader bytes are separate. - -Tests ------ - -``paimon-bounded-table-scan-test`` exercises real Parquet full-field and ORC -``id``-projection plan/read parity (existing ORC TIME decoding is outside this change), -exact and insufficient quotas, shared concurrent admission, allocation failures at -every arena and publication allocation, escaped-split ownership, malformed metadata, -wrong writer field types, duplicate fields/files and unsupported profile rejection. -The GNU static target additionally observes actual allocation requests. The existing -core tests exercise the unchanged ordinary scan path. diff --git a/docs/source/user_guide/read.rst b/docs/source/user_guide/read.rst index a94776353..b07cf0959 100644 --- a/docs/source/user_guide/read.rst +++ b/docs/source/user_guide/read.rst @@ -41,6 +41,47 @@ cross-language task scheduling and interaction (e.g., Java and C++), substantial across the two language ecosystems. +Planning with a custom memory pool +---------------------------------- + +Use ``ScanContextBuilder::WithMemoryPool(pool)`` with ordinary +``TableScan::Create`` and ``CreatePlan`` to supply an application-owned pool. +Planning continues to use the normal metadata decoders, filters and caches. +A pool that refuses an allocation can throw ``std::bad_alloc``. The caller must +handle both an exception from the planning call and a failed ``Result``: adapters +such as the Arrow memory pool can translate allocation refusal into an error. +A failed call must not be treated as an empty or partial successful plan. + +The budget covers only requests routed through the supplied pool. STL containers, +metadata parser storage, shared services and other allocations may bypass it. +It is not a limit on total planning memory, data-reader memory or process RSS. +Unaccounted allocations are not guaranteed to be small merely because the pool +is bounded. Applications requiring a stronger bound need a separate policy and +validation of the uncovered allocations. + +The application chooses the accounting policy in its pool. A **live-byte** limit +charges outstanding requested bytes and returns credit on ``Free``. A growing +``Realloc`` must reserve the increase before requesting storage; on failure it +must preserve the original allocation and its charge. A **cumulative** limit +instead charges allocation work without refunding frees. These are different +contracts: freeing temporary buffers permits another allocation under a live-byte +limit, but not necessarily under a cumulative limit. Neither policy accounts for +allocations that do not reach that pool. ``MemoryPool::CurrentUsage`` describes +live usage; the scan API does not install either budget policy for the caller. + +Keep a custom pool alive until all objects using its allocations have been +released, including retained plans, separately retained splits, cache entries +and executor task closures. A plan or split does not automatically retain a +shared owner of every custom pool. It can outlive its scan when the caller keeps +the pool alive. Use a thread-safe pool, or synchronize all its operations including +cleanup, when planning tasks or consumers can run concurrently. A scan itself +must not be called concurrently. + +When ordinary manifest planning fails, it waits for already accepted metadata +tasks before unwinding the task submission scope. This does not cancel native +I/O or force an application executor to discard completed task closures; their +retained allocations remain subject to the pool lifetime rule above. + Schema Evolution ----------------------- Scope and Compatibility diff --git a/include/paimon/memory/planning_budget.h b/include/paimon/memory/planning_budget.h deleted file mode 100644 index 071110c2b..000000000 --- a/include/paimon/memory/planning_budget.h +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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. - */ - -#pragma once - -#include -#include -#include - -#include "paimon/memory/memory_pool.h" -#include "paimon/result.h" -#include "paimon/visibility.h" - -namespace paimon { -class PlanningBudgetAccess; - -/// Query-owned cumulative allocation admission for bounded metadata planning. -/// Frees do not replenish admission: this deliberately also bounds temporary peaks. -/// The limit covers requested storage, not allocator overhead or process RSS. -/// Admission is atomic, but upstream Malloc/Free calls are not serialized. Overlapping -/// scans or destruction of escaped plans/splits require a thread-safe upstream (such -/// as GetDefaultPool()), or caller synchronization covering operations and cleanup. -class PAIMON_EXPORT PlanningBudget { - public: - static Result> Create( - uint64_t byte_limit, const std::shared_ptr& upstream = GetDefaultPool()); - - uint64_t ByteLimit() const { - return limit_; - } - uint64_t BytesAdmitted() const { - return admitted_.load(); - } - - private: - friend class PlanningBudgetAccess; - PlanningBudget(uint64_t limit, std::shared_ptr upstream); - const uint64_t limit_; - std::shared_ptr upstream_; - std::atomic admitted_{0}; - // The first allocation failure is terminal across scans sharing this budget. - std::atomic failure_{0}; -}; -} // namespace paimon diff --git a/include/paimon/scan_context.h b/include/paimon/scan_context.h index 6fbb006f0..efee52743 100644 --- a/include/paimon/scan_context.h +++ b/include/paimon/scan_context.h @@ -225,6 +225,9 @@ class PAIMON_EXPORT ScanContextBuilder { /// @param memory_pool The memory pool to use. /// @return Reference to this builder for method chaining. /// @note if not set, memory_pool is default pool + /// A custom pool controls only allocations routed through it, not all planning memory. + /// Keep it alive while any plan, split, cache entry, or executor task can retain its + /// allocations; destroying the scan alone does not release all such objects. ScanContextBuilder& WithMemoryPool(const std::shared_ptr& memory_pool); /// Set custom executor for task execution. /// @param executor The executor to use. diff --git a/include/paimon/table/source/table_scan.h b/include/paimon/table/source/table_scan.h index 1cde8f221..8bae74c41 100644 --- a/include/paimon/table/source/table_scan.h +++ b/include/paimon/table/source/table_scan.h @@ -32,7 +32,6 @@ namespace paimon { class ScanContext; -class PlanningBudget; /// A scanner interface for reading table's meta and create a plan. class PAIMON_EXPORT TableScan { @@ -43,13 +42,6 @@ class PAIMON_EXPORT TableScan { /// @return A Result containing a unique pointer to the `TableScan` instance. static Result> Create(std::unique_ptr context); - /// Create a synchronous scan with pre-allocation planning-byte admission. - /// See docs/source/user_guide/bounded_planning.rst for the admitted profile, - /// cumulative-byte contract, allocator qualification and ownership boundaries. - /// Unsupported inputs return an error without invoking the unrestricted planner. - static Result> CreateWithPlanningBudget( - std::unique_ptr context, const std::shared_ptr& budget); - virtual ~TableScan() = default; /// Create a scan plan. diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt index 2ba047a4c..7726b7bad 100644 --- a/src/paimon/CMakeLists.txt +++ b/src/paimon/CMakeLists.txt @@ -110,7 +110,6 @@ set(PAIMON_COMMON_SRCS common/lookup/lookup_store_factory.cpp common/memory/bytes.cpp common/memory/memory_pool.cpp - common/memory/planning_budget.cpp common/memory/memory_segment.cpp common/memory/memory_segment_utils.cpp common/memory/memory_slice.cpp @@ -482,7 +481,6 @@ set(PAIMON_CORE_SRCS core/table/source/startup_mode.cpp core/table/source/table_read.cpp core/table/source/table_scan.cpp - core/table/source/bounded_table_scan.cpp core/table/source/data_evolution_batch_scan.cpp core/table/source/data_evolution_full_text_read.cpp core/table/source/data_evolution_full_text_scan.cpp @@ -531,46 +529,6 @@ if(PAIMON_ENABLE_REST) rest/rest_util.cpp) endif() -# The bounded decoder uses Zstd's static-only workspace API. Ordinary scans also -# support shared Zstd, so disable only the bounded entry point for that build. -set(PAIMON_BOUNDED_PLANNING_ZSTD OFF) -set(_bounded_zstd_target zstd) -get_target_property(_bounded_zstd_type ${_bounded_zstd_target} TYPE) -if(_bounded_zstd_type STREQUAL "INTERFACE_LIBRARY") - get_target_property(_bounded_zstd_links zstd INTERFACE_LINK_LIBRARIES) - list(LENGTH _bounded_zstd_links _bounded_zstd_link_count) - if(_bounded_zstd_link_count EQUAL 1 AND TARGET "${_bounded_zstd_links}") - set(_bounded_zstd_target ${_bounded_zstd_links}) - get_target_property(_bounded_zstd_type ${_bounded_zstd_target} TYPE) - endif() -endif() -get_target_property(_bounded_zstd_location ${_bounded_zstd_target} IMPORTED_LOCATION) -if(_bounded_zstd_type STREQUAL "STATIC_LIBRARY" - OR (_bounded_zstd_type STREQUAL "UNKNOWN_LIBRARY" AND _bounded_zstd_location MATCHES - "\\.a$")) - if(TARGET zstd_ep) - # The pinned bundled static library is built after configuration. - set(PAIMON_BOUNDED_PLANNING_ZSTD ON) - else() - include(CMakePushCheckState) - include(CheckCXXSourceCompiles) - cmake_push_check_state(RESET) - set(CMAKE_REQUIRED_LIBRARIES zstd) - # Reconfiguration may select a different system library in the same build. - unset(PAIMON_BOUNDED_ZSTD_STATIC_API CACHE) - check_cxx_source_compiles("#define ZSTD_STATIC_LINKING_ONLY -#include -int main() { - auto volatile estimate = &ZSTD_estimateDStreamSize_fromFrame; - auto volatile initialize = &ZSTD_initStaticDStream; - return !estimate || !initialize; -}" - PAIMON_BOUNDED_ZSTD_STATIC_API) - cmake_pop_check_state() - set(PAIMON_BOUNDED_PLANNING_ZSTD ${PAIMON_BOUNDED_ZSTD_STATIC_API}) - endif() -endif() - add_paimon_lib(paimon SOURCES ${PAIMON_COMMON_SRCS} @@ -580,7 +538,6 @@ add_paimon_lib(paimon tbb glog fmt - zstd roaring_bitmap xxhash Threads::Threads @@ -593,7 +550,6 @@ add_paimon_lib(paimon tbb glog fmt - zstd roaring_bitmap xxhash Threads::Threads @@ -603,11 +559,6 @@ add_paimon_lib(paimon ${PAIMON_REST_LINK_LIBS} SHARED_LINK_FLAGS ${PAIMON_VERSION_SCRIPT_FLAGS}) -set_property(SOURCE core/table/source/bounded_table_scan.cpp - APPEND - PROPERTY COMPILE_DEFINITIONS - PAIMON_BOUNDED_PLANNING_ZSTD=$ -) add_subdirectory(common/file_index) add_subdirectory(common/global_index) @@ -628,44 +579,6 @@ if(PAIMON_BUILD_TESTS) set_tests_properties(paimon-executor-submission-test PROPERTIES TIMEOUT 30) endif() - set(PAIMON_BOUNDED_TEST_LIB paimon_shared) - if(PAIMON_BUILD_STATIC) - set(PAIMON_BOUNDED_TEST_LIB paimon_static) - endif() - add_paimon_test(bounded_table_scan_test - SOURCES - core/table/source/bounded_table_scan_test.cpp - STATIC_LINK_LIBS - ${PAIMON_BOUNDED_TEST_LIB} - test_utils_static - zstd - ${PAIMON_LOCAL_FILE_SYSTEM_STATIC_LINK_LIBS} - ${PAIMON_PARQUET_FILE_FORMAT_STATIC_LINK_LIBS} - ${PAIMON_ORC_FILE_FORMAT_STATIC_LINK_LIBS} - ${PAIMON_AVRO_FILE_FORMAT_STATIC_LINK_LIBS} - ${GTEST_LINK_TOOLCHAIN}) - target_compile_definitions(paimon-bounded-table-scan-test - PRIVATE PAIMON_BOUNDED_PLANNING_ZSTD=$ - ) - include(CheckCXXSourceCompiles) - check_cxx_source_compiles("#include -#if !defined(__GLIBCXX__) || !_GLIBCXX_USE_CXX11_ABI || _GLIBCXX_RELEASE < 9 -#error Unsupported allocation observation ABI -#endif -int main() { return 0; }" - PAIMON_PLANNING_TEST_GNU_ABI) - if(PAIMON_BUILD_STATIC - AND CMAKE_SYSTEM_NAME STREQUAL "Linux" - AND CMAKE_SIZEOF_VOID_P EQUAL 8 - AND PAIMON_PLANNING_TEST_GNU_ABI) - target_compile_definitions(paimon-bounded-table-scan-test - PRIVATE PAIMON_TEST_PLANNING_ALLOCATIONS) - # Include out-of-line basic_string constructors in the allocation hook. - target_link_options(paimon-bounded-table-scan-test PRIVATE "-static-libstdc++" - "LINKER:--wrap=_Znwm") - endif() - set_tests_properties(paimon-bounded-table-scan-test PROPERTIES TIMEOUT 120) - # The shared HTTP client is compiled only for the components that need libcurl, # so its test follows the same gate. set(PAIMON_COMMON_HTTP_CLIENT_TEST_SRCS) @@ -1127,6 +1040,7 @@ int main() { return 0; }" core/table/source/snapshot/snapshot_reader_test.cpp core/table/source/startup_mode_test.cpp core/table/source/table_scan_test.cpp + core/table/source/planning_memory_pool_test.cpp core/table/system/system_table_test.cpp core/tag/tag_test.cpp core/utils/blob_view_lookup_test.cpp diff --git a/src/paimon/common/memory/planning_budget.cpp b/src/paimon/common/memory/planning_budget.cpp deleted file mode 100644 index 0e0929d1c..000000000 --- a/src/paimon/common/memory/planning_budget.cpp +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 "paimon/memory/planning_budget.h" - -#include - -namespace paimon { -PlanningBudget::PlanningBudget(uint64_t limit, std::shared_ptr upstream) - : limit_(limit), upstream_(std::move(upstream)) {} - -Result> PlanningBudget::Create( - uint64_t byte_limit, const std::shared_ptr& upstream) { - if (!upstream) { - return Status::Invalid("Planning budget requires an upstream allocator"); - } - return std::shared_ptr(new PlanningBudget(byte_limit, upstream)); -} -} // namespace paimon diff --git a/src/paimon/common/memory/planning_budget_internal.h b/src/paimon/common/memory/planning_budget_internal.h deleted file mode 100644 index 1fda3881f..000000000 --- a/src/paimon/common/memory/planning_budget_internal.h +++ /dev/null @@ -1,143 +0,0 @@ -/* - * 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. - */ - -#pragma once - -#include -#include -#include -#include -#include -#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && _GLIBCXX_RELEASE >= 9 -#include -#endif -#include -#include - -#include "paimon/memory/planning_budget.h" - -namespace paimon { -class PlanningBudgetAccess { - public: - static void Reserve(const std::shared_ptr& budget, uint64_t bytes) { - if (budget->failure_.load() != 0) { - throw std::bad_alloc(); - } - uint64_t used = budget->admitted_.load(); - do { - if (bytes > budget->limit_ - used) { - uint8_t expected = 0; - budget->failure_.compare_exchange_strong(expected, 1); - throw std::bad_alloc(); - } - } while (!budget->admitted_.compare_exchange_weak(used, used + bytes)); - } - - static void* Allocate(const std::shared_ptr& budget, size_t bytes, - size_t alignment) { - Reserve(budget, bytes); - try { - void* result = budget->upstream_->Malloc(bytes, std::max(alignment, alignof(void*))); - if (bytes != 0 && result == nullptr) { - throw std::bad_alloc(); - } - return result; - } catch (const std::bad_alloc&) { - uint8_t expected = 0; - budget->failure_.compare_exchange_strong(expected, 2); - throw; - } - } - - static void Free(const std::shared_ptr& budget, void* ptr, size_t bytes, - size_t alignment) { - budget->upstream_->Free(ptr, bytes, std::max(alignment, alignof(void*))); - } - - static const std::shared_ptr& Upstream( - const std::shared_ptr& budget) { - return budget->upstream_; - } - - static void MarkAllocationFailure(const std::shared_ptr& budget) { - uint8_t expected = 0; - budget->failure_.compare_exchange_strong(expected, 2); - } - - static Status Failure(const std::shared_ptr& budget) { - if (budget->failure_.load() == 1) { - return Status::CapacityError("Native planning byte budget exhausted"); - } - return Status::OutOfMemory("Native planning allocation failed"); - } -}; - -#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && _GLIBCXX_RELEASE >= 9 -class PlanningResource : public std::pmr::memory_resource { - public: - explicit PlanningResource(std::shared_ptr budget) - : budget_(std::move(budget)) {} - const std::shared_ptr& Budget() const { - return budget_; - } - - private: - void* do_allocate(size_t bytes, size_t alignment) override { - return PlanningBudgetAccess::Allocate(budget_, bytes, alignment); - } - void do_deallocate(void* ptr, size_t bytes, size_t alignment) override { - PlanningBudgetAccess::Free(budget_, ptr, bytes, alignment); - } - bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override { - return this == &other; - } - std::shared_ptr budget_; -}; - -#endif - -template -class PlanningAllocator { - public: - using value_type = T; - explicit PlanningAllocator(std::shared_ptr budget) - : budget(std::move(budget)) {} - template - explicit PlanningAllocator(const PlanningAllocator& other) : budget(other.budget) {} - T* allocate(size_t count) { - if (count > std::numeric_limits::max() / sizeof(T)) { - throw std::bad_alloc(); - } - return static_cast( - PlanningBudgetAccess::Allocate(budget, count * sizeof(T), alignof(T))); - } - void deallocate(T* ptr, size_t count) { - PlanningBudgetAccess::Free(budget, ptr, count * sizeof(T), alignof(T)); - } - template - bool operator==(const PlanningAllocator& other) const { - return budget == other.budget; - } - template - bool operator!=(const PlanningAllocator& other) const { - return !(*this == other); - } - std::shared_ptr budget; -}; -} // namespace paimon diff --git a/src/paimon/core/operation/file_store_scan.cpp b/src/paimon/core/operation/file_store_scan.cpp index ca2de308a..b6fbd1ced 100644 --- a/src/paimon/core/operation/file_store_scan.cpp +++ b/src/paimon/core/operation/file_store_scan.cpp @@ -37,6 +37,7 @@ #include "paimon/common/predicate/literal_converter.h" #include "paimon/common/types/data_field.h" #include "paimon/common/utils/field_type_utils.h" +#include "paimon/common/utils/scope_guard.h" #include "paimon/core/io/data_file_meta.h" #include "paimon/core/io/file_index_evaluator.h" #include "paimon/core/io/file_index_options.h" @@ -323,6 +324,11 @@ Status FileStoreScan::ReadManifestsWithSnapshot(const Snapshot& snapshot, return metas; }; std::vector>>> futures; + ScopeGuard drain([&futures] { + for (auto& future : futures) { + if (future.valid()) future.wait(); + } + }); futures.reserve(2); futures.push_back( Via(executor_.get(), [read_list]() { return read_list(/*base=*/true); })); @@ -352,6 +358,12 @@ Status FileStoreScan::ReadFileEntries(const std::vector& manif bool apply_scan_filter, std::vector* manifest_entries) const { std::vector>>> futures; + ScopeGuard drain([&futures] { + for (auto& future : futures) { + if (future.valid()) future.wait(); + } + }); + futures.reserve(manifest_metas.size()); for (const auto& meta : manifest_metas) { auto read_meta_task = [this, meta, apply_scan_filter]() -> Result> { @@ -511,6 +523,11 @@ Status FileStoreScan::ReadAndMergeBucketFileEntries( const bool inferred_bucket = !bucket_filter_ && bucket_selector_ != nullptr; if (core_options_.ScanManifestEntryLazyDecodeEnabled()) { std::vector>>> futures; + ScopeGuard drain([&futures] { + for (auto& future : futures) { + if (future.valid()) future.wait(); + } + }); futures.reserve(manifest_metas.size()); for (const auto& meta : manifest_metas) { auto read_meta_task = [this, meta, bucket, diff --git a/src/paimon/core/table/source/bounded_table_scan.cpp b/src/paimon/core/table/source/bounded_table_scan.cpp deleted file mode 100644 index 319478997..000000000 --- a/src/paimon/core/table/source/bounded_table_scan.cpp +++ /dev/null @@ -1,1193 +0,0 @@ -/* - * 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. - */ - -#define ZSTD_STATIC_LINKING_ONLY -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "paimon/common/memory/planning_budget_internal.h" -#include "paimon/core/io/data_file_meta.h" -#include "paimon/core/manifest/file_source.h" -#include "paimon/core/stats/simple_stats.h" -#include "paimon/core/table/source/data_split_impl.h" -#include "paimon/scan_context.h" -#include "paimon/table/source/table_scan.h" -#include "rapidjson/document.h" - -namespace paimon { -#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && \ - _GLIBCXX_RELEASE >= 9 && PAIMON_BOUNDED_PLANNING_ZSTD -namespace { -constexpr size_t kMaximumDepth = 64; - -// RapidJSON's DOM AND parser stack use this allocator. No default arena is used. -class JsonAllocator { - public: - static constexpr bool kNeedFree = true; - explicit JsonAllocator(PlanningResource* resource = nullptr) : resource_(resource) {} - ~JsonAllocator() { - // RapidJSON may pop owned children before allocating their parent. Its - // exception unwinding cannot reclaim those children after admission fails. - while (head_) { - Free(head_ + 1); - } - } - void* Malloc(size_t size) { - if (size == 0) { - return nullptr; - } - if (size > std::numeric_limits::max() - sizeof(Header)) { - throw std::bad_alloc(); - } - auto* header = - static_cast(resource_->allocate(size + sizeof(Header), alignof(Header))); - header->owner = this; - header->size = size; - header->previous = nullptr; - header->next = head_; - if (head_) { - head_->previous = header; - } - head_ = header; - return header + 1; - } - void* Realloc(void* ptr, size_t, size_t size) { - if (!ptr) { - return Malloc(size); - } - if (size == 0) { - Free(ptr); - return nullptr; - } - auto* old = static_cast(ptr) - 1; - void* result = Malloc(size); - memcpy(result, ptr, std::min(size, old->size)); - Free(ptr); - return result; - } - static void Free(void* ptr) { - if (ptr) { - auto* header = static_cast(ptr) - 1; - auto* owner = header->owner; - if (header->previous) { - header->previous->next = header->next; - } else { - owner->head_ = header->next; - } - if (header->next) { - header->next->previous = header->previous; - } - owner->resource_->deallocate(header, header->size + sizeof(Header), alignof(Header)); - } - } - - private: - struct alignas(std::max_align_t) Header { - JsonAllocator* owner; - size_t size; - Header* previous; - Header* next; - }; - PlanningResource* resource_; - Header* head_ = nullptr; -}; -using Json = rapidjson::GenericValue, JsonAllocator>; -using Document = rapidjson::GenericDocument, JsonAllocator, JsonAllocator>; - -const Json* Member(const Json& object, std::string_view name) { - if (!object.IsObject()) { - return nullptr; - } - auto it = object.FindMember(rapidjson::GenericStringRef(name.data(), name.size())); - return it == object.MemberEnd() ? nullptr : &it->value; -} -std::string_view Text(const Json& value) { - return {value.GetString(), value.GetStringLength()}; -} - -class JsonInput { - public: - explicit JsonInput(PlanningResource* resource) - : allocator_(resource), document_(&allocator_, 256, &allocator_) {} - Status Parse(std::string_view input) { - size_t depth = 0; - bool quoted = false; - bool escaped = false; - for (char ch : input) { - if (ch == '\0') { - return Status::Invalid("Embedded NUL in planning JSON"); - } - if (quoted) { - if (escaped) { - escaped = false; - } else if (ch == '\\') { - escaped = true; - } else if (ch == '"') { - quoted = false; - } - } else if (ch == '"') { - quoted = true; - } else if (ch == '{' || ch == '[') { - if (++depth > kMaximumDepth) { - return Status::Invalid("Planning JSON nesting limit exceeded"); - } - } else if (ch == '}' || ch == ']') { - if (depth == 0) { - return Status::Invalid("Unbalanced planning JSON"); - } - --depth; - } - } - document_.Parse( - input.data(), input.size()); - if (document_.HasParseError()) { - return Status::Invalid("Invalid planning JSON"); - } - return UniqueMembers(document_); - } - const Json& Root() const { - return document_; - } - - private: - static Status UniqueMembers(const Json& value) { - if (value.IsObject()) { - for (auto member = value.MemberBegin(); member != value.MemberEnd(); ++member) { - for (auto previous = value.MemberBegin(); previous != member; ++previous) { - if (Text(previous->name) == Text(member->name)) { - return Status::Invalid("Duplicate planning JSON member"); - } - } - PAIMON_RETURN_NOT_OK(UniqueMembers(member->value)); - } - } else if (value.IsArray()) { - for (const auto& item : value.GetArray()) { - PAIMON_RETURN_NOT_OK(UniqueMembers(item)); - } - } - return Status::OK(); - } - JsonAllocator allocator_; - Document document_; -}; - -class FileDescriptor { - public: - explicit FileDescriptor(int32_t fd) : fd_(fd) {} - ~FileDescriptor() { - if (fd_ >= 0) { - ::close(fd_); - } - } - int32_t Get() const { - return fd_; - } - Status Close() { - int32_t fd = std::exchange(fd_, -1); - if (::close(fd) != 0) { - return Status::IOError("Cannot close planning input"); - } - return Status::OK(); - } - - private: - int32_t fd_; -}; - -Result> ReadFile(const std::pmr::string& path, PlanningResource* resource) { - FileDescriptor fd(::open(path.c_str(), O_RDONLY | O_CLOEXEC)); - if (fd.Get() < 0) { - return Status::IOError("Cannot open bound planning input"); - } - struct stat info{}; - if (::fstat(fd.Get(), &info) != 0 || !S_ISREG(info.st_mode) || info.st_size < 0) { - return Status::IOError("Invalid bound planning input"); - } - if (static_cast(info.st_size) > static_cast(PTRDIFF_MAX)) { - return Status::CapacityError("Planning input length exceeds addressable storage"); - } - std::pmr::vector bytes(static_cast(info.st_size), resource); - size_t done = 0; - while (done < bytes.size()) { - ssize_t count = ::read(fd.Get(), bytes.data() + done, - std::min(bytes.size() - done, static_cast(SSIZE_MAX))); - if (count < 0 && errno == EINTR) { - continue; - } - if (count <= 0) { - return Status::IOError("Truncated bound planning input"); - } - done += count; - } - // Refuse changing inputs rather than accepting a prefix of a file which grew. - char extra; - ssize_t tail; - do { - tail = ::read(fd.Get(), &extra, 1); - } while (tail < 0 && errno == EINTR); - if (tail != 0) { - return Status::IOError("Bound planning input changed while reading"); - } - PAIMON_RETURN_NOT_OK(fd.Close()); - return bytes; -} - -bool Component(std::string_view value) { - return !value.empty() && value != "." && value != ".." && - value.find_first_of("/\\\0", 0, 3) == std::string_view::npos; -} - -class Cursor { - public: - explicit Cursor(std::string_view data) : data_(data) {} - size_t Remaining() const { - return data_.size() - position_; - } - size_t Position() const { - return position_; - } - Result Take(uint64_t count) { - if (count > Remaining()) { - return Status::Invalid("Truncated Avro metadata"); - } - auto result = data_.substr(position_, count); - position_ += count; - return result; - } - Result Long() { - uint64_t bits = 0; - for (uint32_t shift = 0; shift < 70; shift += 7) { - PAIMON_ASSIGN_OR_RAISE(std::string_view byte, Take(1)); - auto value = static_cast(byte[0]); - if (shift == 63 && value > 1) { - return Status::Invalid("Avro integer overflow"); - } - bits |= static_cast(value & 127) << shift; - if ((value & 128) == 0) { - return static_cast(bits >> 1) ^ -static_cast(bits & 1); - } - } - return Status::Invalid("Avro integer overflow"); - } - Result Bytes() { - PAIMON_ASSIGN_OR_RAISE(int64_t size, Long()); - if (size < 0) { - return Status::Invalid("Negative Avro byte length"); - } - return Take(size); - } - - private: - std::string_view data_; - size_t position_ = 0; -}; - -struct Datum { - enum class Kind { Null, Integer, Bytes, Record, Array, Unsupported }; - explicit Datum(PlanningResource* resource) : children(resource) {} - Kind kind = Kind::Null; - int64_t number = 0; - std::string_view bytes; - const Json* schema = nullptr; - std::pmr::vector children; - const Datum* Find(std::string_view name) const { - if (kind != Kind::Record) { - return nullptr; - } - const auto* fields = Member(*schema, "fields"); - if (!fields || !fields->IsArray()) { - return nullptr; - } - for (size_t i = 0; i < fields->Size(); ++i) { - const Json* field_name = Member((*fields)[i], "name"); - if (field_name && field_name->IsString() && Text(*field_name) == name) { - return &children[i]; - } - } - return nullptr; - } -}; - -Result Integer(const Datum& row, std::string_view field) { - const Datum* value = row.Find(field); - if (!value || value->kind != Datum::Kind::Integer) { - return Status::Invalid("Missing integer metadata field"); - } - return value->number; -} -Result String(const Datum& row, std::string_view field) { - const Datum* value = row.Find(field); - if (!value || value->kind != Datum::Kind::Bytes) { - return Status::Invalid("Missing byte metadata field"); - } - return value->bytes; -} -bool Absent(const Datum& row, std::string_view field) { - const Datum* value = row.Find(field); - return !value || value->kind == Datum::Kind::Null; -} -bool EmptyArray(const Datum& row, std::string_view field) { - const Datum* value = row.Find(field); - return Absent(row, field) || (value->kind == Datum::Kind::Array && value->children.empty()); -} -Result> OptionalInteger(const Datum& row, std::string_view field) { - const Datum* value = row.Find(field); - if (!value || value->kind == Datum::Kind::Null) { - return std::optional(); - } - if (value->kind != Datum::Kind::Integer) { - return Status::Invalid("Invalid optional integer metadata"); - } - return std::optional(value->number); -} - -class AvroSchema { - public: - explicit AvroSchema(PlanningResource* resource) - : json_(resource), names_(resource), resource_(resource) {} - Status Parse(std::string_view text) { - PAIMON_RETURN_NOT_OK(json_.Parse(text)); - return Collect(json_.Root(), 0); - } - const Json& Root() const { - return json_.Root(); - } - Result Read(const Json& schema, Cursor* cursor, size_t depth = 0, - bool skip = false) const { - if (depth > kMaximumDepth) { - return Status::Invalid("Recursive Avro metadata schema"); - } - Datum result(resource_); - if (schema.IsArray()) { - PAIMON_ASSIGN_OR_RAISE(int64_t branch, cursor->Long()); - if (branch < 0 || static_cast(branch) >= schema.Size()) { - return Status::Invalid("Invalid Avro union branch"); - } - return Read(schema[branch], cursor, depth + 1, skip); - } - const Json* type = schema.IsObject() ? Member(schema, "type") : &schema; - if (!type || !type->IsString()) { - return Status::Invalid("Invalid Avro metadata type"); - } - std::string_view name = Text(*type); - if (name == "null") { - return result; - } - if (name == "int" || name == "long") { - PAIMON_ASSIGN_OR_RAISE(result.number, cursor->Long()); - if (name == "int" && (result.number < INT32_MIN || result.number > INT32_MAX)) { - return Status::Invalid("Avro int overflow"); - } - result.kind = Datum::Kind::Integer; - } else if (name == "boolean") { - PAIMON_ASSIGN_OR_RAISE(std::string_view byte, cursor->Take(1)); - if (static_cast(byte[0]) > 1) { - return Status::Invalid("Invalid Avro boolean"); - } - result.kind = Datum::Kind::Unsupported; - result.number = byte[0]; - } else if (name == "string" || name == "bytes") { - PAIMON_ASSIGN_OR_RAISE(result.bytes, cursor->Bytes()); - result.kind = Datum::Kind::Bytes; - } else if (name == "float" || name == "double") { - result.kind = Datum::Kind::Unsupported; - PAIMON_RETURN_NOT_OK(cursor->Take(name == "float" ? 4 : 8).status()); - } else if (name == "record") { - const Json* fields = Member(schema, "fields"); - if (!fields || !fields->IsArray()) { - return Status::Invalid("Invalid Avro record fields"); - } - result.kind = Datum::Kind::Record; - result.schema = &schema; - if (!skip) { - result.children.reserve(fields->Size()); - } - for (const auto& field : fields->GetArray()) { - const Json* child = Member(field, "type"); - const Json* field_name = Member(field, "name"); - if (!child || !field_name || !field_name->IsString()) { - return Status::Invalid("Invalid Avro record field"); - } - auto label = Text(*field_name); - const bool discard = skip || label == "_KEY_STATS" || label == "_VALUE_STATS" || - label == "_PARTITION_STATS"; - PAIMON_ASSIGN_OR_RAISE(Datum value, Read(*child, cursor, depth + 1, discard)); - if (!skip) { - result.children.push_back(std::move(value)); - } - } - } else if (name == "array" || name == "map") { - const Json* item = Member(schema, name == "array" ? "items" : "values"); - if (!item) { - return Status::Invalid("Invalid Avro collection schema"); - } - result.kind = Datum::Kind::Array; - while (true) { - PAIMON_ASSIGN_OR_RAISE(int64_t count, cursor->Long()); - if (count == 0) { - break; - } - std::optional end; - if (count < 0) { - if (count == INT64_MIN) { - return Status::Invalid("Avro collection count overflow"); - } - count = -count; - PAIMON_ASSIGN_OR_RAISE(int64_t size, cursor->Long()); - if (size < 0 || static_cast(size) > cursor->Remaining()) { - return Status::Invalid("Invalid Avro collection block size"); - } - end = cursor->Position() + size; - } - // Admitted metadata arrays contain a union tag or scalar per item. - if (static_cast(count) > cursor->Remaining() + 1) { - return Status::Invalid("Impossible Avro collection count"); - } - for (int64_t i = 0; i < count; ++i) { - if (name == "map") { - PAIMON_RETURN_NOT_OK(cursor->Bytes().status()); - } - PAIMON_ASSIGN_OR_RAISE(Datum value, Read(*item, cursor, depth + 1, skip)); - if (!skip) { - result.children.push_back(std::move(value)); - } - } - if (end && cursor->Position() != *end) { - return Status::Invalid("Avro collection block size mismatch"); - } - } - } else if (name == "fixed") { - const Json* size = Member(schema, "size"); - if (!size || !size->IsUint64()) { - return Status::Invalid("Invalid Avro fixed size"); - } - PAIMON_ASSIGN_OR_RAISE(result.bytes, cursor->Take(size->GetUint64())); - result.kind = Datum::Kind::Bytes; - } else if (name == "enum") { - const Json* symbols = Member(schema, "symbols"); - PAIMON_ASSIGN_OR_RAISE(result.number, cursor->Long()); - if (!symbols || !symbols->IsArray() || result.number < 0 || - static_cast(result.number) >= symbols->Size()) { - return Status::Invalid("Invalid Avro enum"); - } - result.kind = Datum::Kind::Unsupported; - } else { - auto it = names_.find(name); - if (it == names_.end()) { - return Status::NotImplemented("Unknown Avro metadata schema reference"); - } - return Read(*it->second, cursor, depth + 1, skip); - } - return result; - } - - private: - Status Collect(const Json& node, size_t depth) { - if (depth > kMaximumDepth) { - return Status::Invalid("Avro schema nesting limit exceeded"); - } - if (node.IsArray()) { - for (const auto& item : node.GetArray()) { - PAIMON_RETURN_NOT_OK(Collect(item, depth + 1)); - } - } else if (node.IsObject()) { - const Json* type = Member(node, "type"); - if (!type) { - return Status::Invalid("Missing Avro schema type"); - } - const Json* name = Member(node, "name"); - if (name) { - if (!name->IsString() || !names_.emplace(Text(*name), &node).second) { - return Status::Invalid("Duplicate Avro metadata type name"); - } - } - if (type->IsString() && Text(*type) == "record") { - const Json* fields = Member(node, "fields"); - if (!fields || !fields->IsArray()) { - return Status::Invalid("Invalid Avro record schema"); - } - for (auto field = fields->Begin(); field != fields->End(); ++field) { - const Json* child = Member(*field, "type"); - const Json* label = Member(*field, "name"); - if (!child || !label || !label->IsString()) { - return Status::Invalid("Missing Avro field name or type"); - } - for (auto previous = fields->Begin(); previous != field; ++previous) { - const Json* other = Member(*previous, "name"); - if (other && other->IsString() && Text(*other) == Text(*label)) { - return Status::Invalid("Duplicate Avro record field name"); - } - } - PAIMON_RETURN_NOT_OK(Collect(*child, depth + 1)); - } - } else { - for (auto label : {"items", "values"}) { - const Json* child = Member(node, label); - if (child) { - PAIMON_RETURN_NOT_OK(Collect(*child, depth + 1)); - } - } - } - } else if (!node.IsString()) { - return Status::Invalid("Invalid Avro schema"); - } - return Status::OK(); - } - JsonInput json_; - std::pmr::unordered_map names_; - PlanningResource* resource_; -}; - -// Validate known fields against their writer schema even for null/empty values. -// Value-only checks cannot distinguish an absent optional from an ignored float. -const Json* Unwrap(const Json& type) { - if (!type.IsArray()) { - return &type; - } - if (type.Size() != 2) { - return nullptr; - } - for (size_t i = 0; i < 2; ++i) { - if (type[i].IsString() && Text(type[i]) == "null") { - return &type[1 - i]; - } - } - return nullptr; -} -bool Matches(const Json& schema, std::string_view expected, std::string_view items = {}) { - const Json* value = Unwrap(schema); - if (!value) { - return false; - } - const Json* type = value->IsObject() ? Member(*value, "type") : value; - if (!type || !type->IsString() || Text(*type) != expected) { - return false; - } - if (!items.empty()) { - const Json* item = Member(*value, "items"); - return item && Matches(*item, items); - } - return true; -} -const Json* FieldType(const Json& record, std::string_view name) { - const Json* value = Unwrap(record); - const Json* fields = value ? Member(*value, "fields") : nullptr; - if (!fields || !fields->IsArray()) { - return nullptr; - } - for (const auto& field : fields->GetArray()) { - const Json* label = Member(field, "name"); - if (label && label->IsString() && Text(*label) == name) { - return Member(field, "type"); - } - } - return nullptr; -} -Status ValidateMetadataSchema(const Json& root, bool manifest_entry) { - if (!Matches(root, "record")) { - return Status::Invalid("Metadata writer root must be a record"); - } - auto required = [](const Json& record, std::string_view name, std::string_view type) -> Status { - const Json* field = FieldType(record, name); - if (!field || !Matches(*field, type)) { - return Status::Invalid("Unsupported required metadata writer field type"); - } - return Status::OK(); - }; - PAIMON_RETURN_NOT_OK(required(root, "_VERSION", "int")); - if (!manifest_entry) { - return required(root, "_FILE_NAME", "string"); - } - PAIMON_RETURN_NOT_OK(required(root, "_KIND", "int")); - PAIMON_RETURN_NOT_OK(required(root, "_PARTITION", "bytes")); - PAIMON_RETURN_NOT_OK(required(root, "_BUCKET", "int")); - PAIMON_RETURN_NOT_OK(required(root, "_TOTAL_BUCKETS", "int")); - PAIMON_RETURN_NOT_OK(required(root, "_FILE", "record")); - const Json& file = *FieldType(root, "_FILE"); - PAIMON_RETURN_NOT_OK(required(file, "_FILE_NAME", "string")); - PAIMON_RETURN_NOT_OK(required(file, "_LEVEL", "int")); - for (auto name : {"_FILE_SIZE", "_ROW_COUNT", "_SCHEMA_ID", "_MIN_SEQUENCE_NUMBER", - "_MAX_SEQUENCE_NUMBER"}) { - PAIMON_RETURN_NOT_OK(required(file, name, "long")); - } - for (auto [name, type] : {std::pair{"_CREATION_TIME", "long"}, - {"_DELETE_ROW_COUNT", "long"}, - {"_FILE_SOURCE", "int"}, - {"_FIRST_ROW_ID", "long"}, - {"_EXTERNAL_PATH", "string"}, - {"_EMBEDDED_FILE_INDEX", "bytes"}}) { - if (const Json* field = FieldType(file, name); field && !Matches(*field, type)) { - return Status::Invalid("Unsupported optional metadata writer field type"); - } - } - for (auto name : - {"_EXTRA_FILES", "_VALUE_STATS_COLS", "_WRITE_COLS", "_WRITE_COLS_SEQUENCES"}) { - const Json* field = FieldType(file, name); - if (field && - !Matches(*field, "array", - std::string_view(name) == "_WRITE_COLS_SEQUENCES" ? "long" : "string")) { - return Status::Invalid("Unsupported collection metadata writer field type"); - } - } - return Status::OK(); -} - -template -Status ReadOcf(const std::pmr::string& path, PlanningResource* resource, bool manifest_entry, - Consumer consumer) { - PAIMON_ASSIGN_OR_RAISE(std::pmr::vector file, ReadFile(path, resource)); - Cursor cursor({file.data(), file.size()}); - PAIMON_ASSIGN_OR_RAISE(std::string_view magic, cursor.Take(4)); - if (magic != std::string_view("Obj\1", 4)) { - return Status::NotImplemented("Bounded planning requires Avro OCF metadata"); - } - std::string_view schema_text; - std::string_view codec = "null"; - bool has_schema = false; - bool has_codec = false; - while (true) { - PAIMON_ASSIGN_OR_RAISE(int64_t count, cursor.Long()); - if (count == 0) { - break; - } - std::optional end; - if (count < 0) { - if (count == INT64_MIN) { - return Status::Invalid("Avro header count overflow"); - } - count = -count; - PAIMON_ASSIGN_OR_RAISE(int64_t size, cursor.Long()); - if (size < 0 || static_cast(size) > cursor.Remaining()) { - return Status::Invalid("Invalid Avro header block"); - } - end = cursor.Position() + size; - } - if (static_cast(count) > cursor.Remaining() / 2) { - return Status::Invalid("Impossible Avro header count"); - } - for (int64_t i = 0; i < count; ++i) { - PAIMON_ASSIGN_OR_RAISE(std::string_view key, cursor.Bytes()); - PAIMON_ASSIGN_OR_RAISE(std::string_view value, cursor.Bytes()); - if (key == "avro.schema") { - if (has_schema) { - return Status::Invalid("Duplicate Avro schema header"); - } - schema_text = value; - has_schema = true; - } else if (key == "avro.codec") { - if (has_codec) { - return Status::Invalid("Duplicate Avro codec header"); - } - codec = value; - has_codec = true; - } - } - if (end && cursor.Position() != *end) { - return Status::Invalid("Avro header block size mismatch"); - } - } - if (!has_schema) { - return Status::Invalid("Missing Avro metadata schema"); - } - if (codec != "null" && codec != "zstandard") { - return Status::NotImplemented("Unsupported bounded metadata codec"); - } - PAIMON_ASSIGN_OR_RAISE(std::string_view sync, cursor.Take(16)); - AvroSchema schema(resource); - PAIMON_RETURN_NOT_OK(schema.Parse(schema_text)); - PAIMON_RETURN_NOT_OK(ValidateMetadataSchema(schema.Root(), manifest_entry)); - std::pmr::vector workspace(resource); - while (cursor.Remaining() != 0) { - PAIMON_ASSIGN_OR_RAISE(int64_t count, cursor.Long()); - PAIMON_ASSIGN_OR_RAISE(int64_t length, cursor.Long()); - if (count <= 0 || length < 0) { - return Status::Invalid("Invalid Avro data block"); - } - PAIMON_ASSIGN_OR_RAISE(std::string_view compressed, cursor.Take(length)); - PAIMON_ASSIGN_OR_RAISE(std::string_view marker, cursor.Take(16)); - if (marker != sync) { - return Status::Invalid("Avro sync marker mismatch"); - } - std::pmr::vector output(resource); - std::string_view payload = compressed; - if (codec == "zstandard") { - if (ZSTD_getFrameContentSize(compressed.data(), compressed.size()) == - ZSTD_CONTENTSIZE_ERROR || - ZSTD_getDictID_fromFrame(compressed.data(), compressed.size()) != 0 || - ZSTD_findFrameCompressedSize(compressed.data(), compressed.size()) != - compressed.size()) { - return Status::Invalid("Invalid bounded Zstandard frame"); - } - const size_t required = - ZSTD_estimateDStreamSize_fromFrame(compressed.data(), compressed.size()); - if (ZSTD_isError(required) || - required > static_cast(PTRDIFF_MAX) - sizeof(uint64_t)) { - return Status::Invalid("Invalid bounded Zstandard workspace size"); - } - workspace.resize((required + sizeof(uint64_t) - 1) / sizeof(uint64_t)); - auto* stream = - ZSTD_initStaticDStream(workspace.data(), workspace.size() * sizeof(uint64_t)); - if (!stream || ZSTD_isError(ZSTD_initDStream(stream))) { - return Status::Invalid("Cannot initialize bounded Zstandard decoder"); - } - ZSTD_inBuffer input{compressed.data(), compressed.size(), 0}; - size_t remaining = 1; - while (remaining != 0) { - const size_t before = output.size(); - if (before > static_cast(PTRDIFF_MAX) - 65536) { - return Status::CapacityError("Decoded metadata exceeds addressable storage"); - } - output.resize(before + 65536); - ZSTD_outBuffer destination{output.data() + before, output.size() - before, 0}; - const size_t consumed = input.pos; - remaining = ZSTD_decompressStream(stream, &destination, &input); - if (ZSTD_isError(remaining) || - (remaining != 0 && input.pos == consumed && destination.pos == 0)) { - return Status::Invalid("Cannot decode bounded Zstandard frame"); - } - output.resize(before + destination.pos); - } - if (input.pos != input.size) { - return Status::Invalid("Trailing bounded Zstandard frame bytes"); - } - payload = {output.data(), output.size()}; - } - if (static_cast(count) > payload.size()) { - return Status::Invalid("Impossible Avro record count"); - } - Cursor records(payload); - for (int64_t i = 0; i < count; ++i) { - PAIMON_ASSIGN_OR_RAISE(Datum row, schema.Read(schema.Root(), &records)); - PAIMON_RETURN_NOT_OK(consumer(row)); - } - if (records.Remaining() != 0) { - return Status::Invalid("Avro record count or block size mismatch"); - } - } - return Status::OK(); -} - -// The only ordinary-STL variable allocations in finite planning are exact-size -// native publication constructors. This profile is qualified for the libstdc++ -// C++11 ABI: basic_string's forward-range/copy constructors request n+1 chars; -// vector(n) requests n elements. See the source/allocator-observed proof in docs. -std::string NativeString(std::string_view text, const std::shared_ptr& budget) { - PlanningBudgetAccess::Reserve(budget, text.size() + 1); - return std::string(text.data(), text.size()); -} - -struct Entry { - explicit Entry(PlanningResource* resource) : name(resource) {} - std::pmr::string name; - int32_t bucket = 0; - int32_t total_buckets = 0; - int32_t level = 0; - int64_t file_size = 0; - int64_t rows = 0; - int64_t schema_id = 0; - int64_t min_sequence = 0; - int64_t max_sequence = 0; - int64_t creation = 0; - std::optional first_row; - std::optional source; -}; - -class BudgetPlan : public Plan { - public: - BudgetPlan(int64_t snapshot, std::vector> splits) - : snapshot_(snapshot), splits_(std::move(splits)) {} - std::optional SnapshotId() const override { - return snapshot_; - } - const std::vector>& Splits() const override { - return splits_; - } - - private: - int64_t snapshot_; - std::vector> splits_; -}; - -class BoundedTableScan : public TableScan { - public: - BoundedTableScan(std::unique_ptr context, std::shared_ptr budget) - : context_(std::move(context)), resource_(std::move(budget)) {} - - Result> CreatePlan() override { - if (failure_) { - return *failure_; - } - if (finished_) { - return Status::Invalid("end of scan"); - } - finished_ = true; - try { - auto result = BuildPlan(); - if (!result.ok()) { - failure_ = result.status(); - } - return result; - } catch (const std::bad_alloc&) { - PlanningBudgetAccess::MarkAllocationFailure(resource_.Budget()); - failure_ = PlanningBudgetAccess::Failure(resource_.Budget()); - } catch (const std::length_error&) { - failure_ = - Status::CapacityError("Planning container length exceeds addressable storage"); - } - return *failure_; - } - - private: - std::string_view Option(const Json& schema, std::string_view key, - std::string_view fallback = {}) const { - for (const auto& item : context_->GetOptions()) { - if (item.first == key) { - return item.second; - } - } - const Json* options = Member(schema, "options"); - const Json* value = options ? Member(*options, key) : nullptr; - return value && value->IsString() ? Text(*value) : fallback; - } - Result> BuildPlan() { - JsonInput schema(&resource_); - PAIMON_RETURN_NOT_OK(schema.Parse(*context_->GetSpecificTableSchema())); - const Json& table = schema.Root(); - const Json* schema_id = Member(table, "id"); - const Json* keys = Member(table, "primaryKeys"); - const Json* partitions = Member(table, "partitionKeys"); - if (!schema_id || !schema_id->IsInt64() || !keys || !keys->IsArray() || !keys->Empty() || - !partitions || !partitions->IsArray() || !partitions->Empty()) { - return Status::NotImplemented( - "Bounded planning requires an unpartitioned append-only schema"); - } - if (Option(table, "bucket", "-1") != "-1" || Option(table, "branch", "main") != "main" || - Option(table, "manifest.format", "avro") != "avro" || - Option(table, "data-evolution.enabled", "false") != "false" || - Option(table, "deletion-vectors.enabled", "false") != "false") { - return Status::NotImplemented("Unsupported bounded planning profile"); - } - auto check_option = [](std::string_view key) -> Status { - if (key.substr(0, 5) == "scan." && key != "scan.snapshot-id") { - return Status::NotImplemented( - "Bounded planning requires explicit numeric snapshot selection"); - } - return Status::OK(); - }; - if (const Json* options = Member(table, "options")) { - if (!options->IsObject()) { - return Status::Invalid("Invalid retained schema options"); - } - for (const auto& item : options->GetObject()) { - if (!item.value.IsString()) { - return Status::Invalid("Invalid retained schema option value"); - } - PAIMON_RETURN_NOT_OK(check_option(Text(item.name))); - } - } - for (const auto& item : context_->GetOptions()) { - PAIMON_RETURN_NOT_OK(check_option(item.first)); - } - auto format = Option(table, "file.format", "parquet"); - if (format != "parquet" && format != "orc") { - return Status::NotImplemented("Unsupported bounded data format"); - } - std::string_view snapshot_text = Option(table, "scan.snapshot-id"); - int64_t snapshot_id = 0; - if (snapshot_text.empty()) { - return Status::Invalid("Missing bound numeric snapshot id"); - } - auto parsed = std::from_chars(snapshot_text.data(), - snapshot_text.data() + snapshot_text.size(), snapshot_id); - if (parsed.ec != std::errc() || parsed.ptr != snapshot_text.data() + snapshot_text.size() || - snapshot_id < 1) { - return Status::Invalid("Bounded planning requires a positive numeric snapshot id"); - } - std::pmr::string root(context_->GetPath(), &resource_); - if (std::string_view(root).substr(0, 7) == "file://") { - root.erase(0, 7); - } - if (root.find("://") != std::pmr::string::npos || root.empty()) { - return Status::NotImplemented("Bounded planning currently supports local files"); - } - while (root.size() > 1 && root.back() == '/') { - root.pop_back(); - } - std::pmr::string snapshot_path(root, &resource_); - snapshot_path += "/snapshot/snapshot-"; - char id[32]; - auto id_result = std::to_chars(id, id + sizeof(id), snapshot_id); - snapshot_path.append(id, id_result.ptr); - PAIMON_ASSIGN_OR_RAISE(std::pmr::vector snapshot_bytes, - ReadFile(snapshot_path, &resource_)); - JsonInput snapshot(&resource_); - PAIMON_RETURN_NOT_OK(snapshot.Parse({snapshot_bytes.data(), snapshot_bytes.size()})); - const Json* actual_id = Member(snapshot.Root(), "id"); - const Json* actual_schema = Member(snapshot.Root(), "schemaId"); - if (!actual_id || !actual_id->IsInt64() || actual_id->GetInt64() != snapshot_id || - !actual_schema || !actual_schema->IsInt64() || - actual_schema->GetInt64() != schema_id->GetInt64()) { - return Status::Invalid("Bound snapshot/schema identity mismatch"); - } - std::pmr::vector manifests(&resource_); - for (auto label : {"baseManifestList", "deltaManifestList"}) { - const Json* name = Member(snapshot.Root(), label); - if (!name || !name->IsString() || !Component(Text(*name))) { - return Status::Invalid("Invalid bound manifest-list identity"); - } - std::pmr::string path(root, &resource_); - path += "/manifest/"; - path.append(name->GetString(), name->GetStringLength()); - PAIMON_RETURN_NOT_OK(ReadOcf(path, &resource_, false, [&](const Datum& row) -> Status { - PAIMON_ASSIGN_OR_RAISE(int64_t version, Integer(row, "_VERSION")); - PAIMON_ASSIGN_OR_RAISE(std::string_view filename, String(row, "_FILE_NAME")); - if (version != 2 || !Component(filename)) { - return Status::Invalid("Invalid manifest-list record"); - } - manifests.emplace_back(filename); - return Status::OK(); - })); - } - std::pmr::vector entries(&resource_); - for (const auto& manifest : manifests) { - std::pmr::string path(root, &resource_); - path += "/manifest/"; - path += manifest; - PAIMON_RETURN_NOT_OK(ReadOcf(path, &resource_, true, [&](const Datum& row) -> Status { - PAIMON_ASSIGN_OR_RAISE(int64_t version, Integer(row, "_VERSION")); - PAIMON_ASSIGN_OR_RAISE(int64_t kind, Integer(row, "_KIND")); - if (version != 2) { - return Status::NotImplemented("Unsupported manifest version"); - } - if (kind == 1) { - return Status::NotImplemented( - "Bounded ADD-only profile does not admit delete manifests"); - } - if (kind != 0) { - return Status::Invalid("Invalid manifest row kind"); - } - PAIMON_ASSIGN_OR_RAISE(std::string_view partition, String(row, "_PARTITION")); - if (partition.size() != 12 || std::any_of(partition.begin(), partition.end(), - [](char c) { return c != 0; })) { - return Status::NotImplemented("Bounded planning requires empty partitions"); - } - const Datum* file = row.Find("_FILE"); - if (!file || file->kind != Datum::Kind::Record) { - return Status::Invalid("Missing file metadata"); - } - Entry entry(&resource_); - PAIMON_ASSIGN_OR_RAISE(std::string_view filename, String(*file, "_FILE_NAME")); - const auto dot = filename.find_last_of('.'); - if (!Component(filename) || dot == std::string_view::npos || - filename.substr(dot + 1) != format) { - return Status::NotImplemented( - "Bounded planning requires Parquet or ORC data files"); - } - entry.name = filename; - PAIMON_ASSIGN_OR_RAISE(int64_t bucket, Integer(row, "_BUCKET")); - PAIMON_ASSIGN_OR_RAISE(int64_t total, Integer(row, "_TOTAL_BUCKETS")); - PAIMON_ASSIGN_OR_RAISE(int64_t level, Integer(*file, "_LEVEL")); - if (bucket < 0 || bucket > INT32_MAX || total < INT32_MIN || total > INT32_MAX || - level < 0 || level > INT32_MAX) { - return Status::Invalid("Invalid file bucket or level"); - } - entry.bucket = bucket; - entry.total_buckets = total; - entry.level = level; - PAIMON_ASSIGN_OR_RAISE(entry.file_size, Integer(*file, "_FILE_SIZE")); - PAIMON_ASSIGN_OR_RAISE(entry.rows, Integer(*file, "_ROW_COUNT")); - PAIMON_ASSIGN_OR_RAISE(entry.schema_id, Integer(*file, "_SCHEMA_ID")); - PAIMON_ASSIGN_OR_RAISE(entry.min_sequence, Integer(*file, "_MIN_SEQUENCE_NUMBER")); - PAIMON_ASSIGN_OR_RAISE(entry.max_sequence, Integer(*file, "_MAX_SEQUENCE_NUMBER")); - PAIMON_ASSIGN_OR_RAISE(entry.first_row, OptionalInteger(*file, "_FIRST_ROW_ID")); - PAIMON_ASSIGN_OR_RAISE(std::optional creation, - OptionalInteger(*file, "_CREATION_TIME")); - entry.creation = creation.value_or(0); - PAIMON_ASSIGN_OR_RAISE(std::optional deleted, - OptionalInteger(*file, "_DELETE_ROW_COUNT")); - PAIMON_ASSIGN_OR_RAISE(std::optional source, - OptionalInteger(*file, "_FILE_SOURCE")); - if (source) { - if (*source < 0 || *source > 1) { - return Status::Invalid("Invalid data file source"); - } - PAIMON_ASSIGN_OR_RAISE(entry.source, FileSource::FromByteValue(*source)); - } - if (entry.file_size < 0 || entry.rows < 0 || (deleted && *deleted != 0) || - (entry.first_row && - (*entry.first_row < 0 || entry.rows > INT64_MAX - *entry.first_row)) || - entry.schema_id != schema_id->GetInt64() || - !EmptyArray(*file, "_EXTRA_FILES") || !Absent(*file, "_EMBEDDED_FILE_INDEX") || - !Absent(*file, "_EXTERNAL_PATH") || !Absent(*file, "_WRITE_COLS") || - !Absent(*file, "_WRITE_COLS_SEQUENCES")) { - return Status::NotImplemented("Unsupported bounded data-file metadata"); - } - entries.push_back(std::move(entry)); - return Status::OK(); - })); - } - std::pmr::vector sorted(entries.size(), &resource_); - for (size_t i = 0; i < entries.size(); ++i) { - sorted[i] = i; - } - auto key = [&](size_t i) { - return std::tie(entries[i].bucket, entries[i].level, entries[i].name); - }; - std::sort(sorted.begin(), sorted.end(), - [&](size_t left, size_t right) { return key(left) < key(right); }); - for (size_t i = 1; i < sorted.size(); ++i) { - if (key(sorted[i - 1]) == key(sorted[i])) { - return Status::Invalid("Duplicate ADD metadata file"); - } - } - if (entries.size() > SIZE_MAX / sizeof(std::shared_ptr)) { - throw std::bad_alloc(); - } - PlanningBudgetAccess::Reserve(resource_.Budget(), - entries.size() * sizeof(std::shared_ptr)); - std::vector> splits(entries.size()); - for (size_t i = 0; i < entries.size(); ++i) { - const Entry& entry = entries[i]; - std::string filename = NativeString(entry.name, resource_.Budget()); - // DataFileMeta copies its sole nonempty string. Other admitted fields - // below are empty/optional scalars; the shared block is allocator-routed. - PlanningBudgetAccess::Reserve(resource_.Budget(), entry.name.size() + 1); - auto meta = std::allocate_shared( - PlanningAllocator(resource_.Budget()), filename, entry.file_size, - entry.rows, BinaryRow::EmptyRow(), BinaryRow::EmptyRow(), SimpleStats::EmptyStats(), - SimpleStats::EmptyStats(), entry.min_sequence, entry.max_sequence, entry.schema_id, - entry.level, std::vector>(), - Timestamp(entry.creation, 0), std::optional(0), nullptr, entry.source, - std::nullopt, std::nullopt, entry.first_row, std::nullopt, std::nullopt); - PlanningBudgetAccess::Reserve(resource_.Budget(), - sizeof(std::shared_ptr)); - std::vector> files(1); - files[0] = std::move(meta); - std::pmr::string bucket_path(root, &resource_); - bucket_path += "/bucket-"; - char bucket_digits[32]; - auto number = - std::to_chars(bucket_digits, bucket_digits + sizeof(bucket_digits), entry.bucket); - bucket_path.append(bucket_digits, number.ptr); - std::string native_path = NativeString(bucket_path, resource_.Budget()); -#if defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI - PlanningBudgetAccess::Reserve( - resource_.Budget(), - sizeof(DataSplitImpl) + - sizeof(std::_Sp_counted_ptr) + - native_path.size() + 1); -#endif - DataSplitImpl::Builder builder(BinaryRow::EmptyRow(), entry.bucket, native_path, - std::move(files)); - builder.WithSnapshot(snapshot_id) - .WithTotalBuckets(entry.total_buckets) - .RawConvertible(true); - PAIMON_ASSIGN_OR_RAISE(std::shared_ptr split, builder.Build()); - splits[i] = std::move(split); - } - PlanningBudgetAccess::Reserve(resource_.Budget(), 0); - std::shared_ptr plan = std::allocate_shared( - PlanningAllocator(resource_.Budget()), snapshot_id, std::move(splits)); - return plan; - } - std::unique_ptr context_; - PlanningResource resource_; - bool finished_ = false; - std::optional failure_; -}; - -} // namespace -#endif - -Result> TableScan::CreateWithPlanningBudget( - std::unique_ptr context, const std::shared_ptr& budget) { -#if !defined(__GLIBCXX__) || !_GLIBCXX_USE_CXX11_ABI || !defined(__linux__) || \ - _GLIBCXX_RELEASE < 9 || !PAIMON_BOUNDED_PLANNING_ZSTD - return Status::NotImplemented( - "Bounded planning requires Linux libstdc++ 9+ C++11 ABI and compatible static Zstd"); -#else - if (ZSTD_versionNumber() != ZSTD_VERSION_NUMBER) { - return Status::NotImplemented( - "Bounded planning requires matching Zstd headers and library"); - } - if (!context || !budget) { - return Status::Invalid("Bounded planning requires context and budget"); - } - if (!context->GetSpecificTableSchema() || context->GetSpecificFileSystem() || - context->IsStreamingMode() || context->GetFormatTable() || context->GetCatalog() || - context->GetRealtimeContext() || context->GetGlobalIndexResult() || - context->GetIdentifier() || context->GetLimit()) { - return Status::NotImplemented("Unsupported bounded scan context"); - } - const auto& filter = context->GetScanFilters(); - if (filter && (filter->GetPredicate() || filter->GetBucketFilter() || - !filter->GetPartitionFilters().empty())) { - return Status::NotImplemented("Bounded planning does not admit planning filters"); - } - if (context->GetMemoryPool() != PlanningBudgetAccess::Upstream(budget)) { - return Status::Invalid("Planning budget and scan must use the same upstream allocator"); - } - try { - // Retaining caller-created input is charged before accepting the scan. - PlanningBudgetAccess::Reserve(budget, sizeof(ScanContext) + sizeof(BoundedTableScan)); - PlanningBudgetAccess::Reserve(budget, context->GetPath().capacity() + 1); - PlanningBudgetAccess::Reserve(budget, context->GetSpecificTableSchema()->capacity() + 1); - if (filter) { - PlanningBudgetAccess::Reserve( - budget, sizeof(std::_Sp_counted_ptr_inplace, - __gnu_cxx::_S_atomic>)); - } - using OptionNode = std::_Rb_tree_node>; - for (const auto& option : context->GetOptions()) { - PlanningBudgetAccess::Reserve(budget, sizeof(OptionNode)); - PlanningBudgetAccess::Reserve(budget, option.first.capacity() + 1); - PlanningBudgetAccess::Reserve(budget, option.second.capacity() + 1); - } - // Shared immutable SDK constants are initialized outside the per-query - // payload arena; their fixed process lifetime is documented separately. - (void)BinaryRow::EmptyRow(); - (void)SimpleStats::EmptyStats(); - return std::unique_ptr(new BoundedTableScan(std::move(context), budget)); - } catch (const std::bad_alloc&) { - PlanningBudgetAccess::MarkAllocationFailure(budget); - return PlanningBudgetAccess::Failure(budget); - } -#endif -} -} // namespace paimon diff --git a/src/paimon/core/table/source/bounded_table_scan_test.cpp b/src/paimon/core/table/source/bounded_table_scan_test.cpp deleted file mode 100644 index 55c861162..000000000 --- a/src/paimon/core/table/source/bounded_table_scan_test.cpp +++ /dev/null @@ -1,714 +0,0 @@ -/* - * 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 - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "arrow/api.h" -#include "arrow/array/concatenate.h" -#include "arrow/c/bridge.h" -#include "gtest/gtest.h" -#include "paimon/common/memory/planning_budget_internal.h" -#include "paimon/core/table/source/data_split_impl.h" -#include "paimon/read_context.h" -#include "paimon/scan_context.h" -#include "paimon/table/source/table_read.h" -#include "paimon/table/source/table_scan.h" -#include "paimon/testing/utils/testharness.h" - -#ifdef PAIMON_TEST_PLANNING_ALLOCATIONS -namespace { -thread_local bool watching = false; -thread_local uint64_t requested = 0; -thread_local uint64_t ordinary_allocations = 0; -thread_local uint64_t fail_ordinary_at = UINT64_MAX; -thread_local bool observed_overdraft = false; -thread_local const paimon::PlanningBudget* observed_budget = nullptr; -void Observe(size_t bytes) { - if (watching) { - requested += bytes; - observed_overdraft |= requested > observed_budget->BytesAdmitted(); - } -} -} // namespace -extern "C" void* __real__Znwm(std::size_t size); -extern "C" void* __wrap__Znwm(std::size_t size) { - if (watching) { - if (ordinary_allocations++ == fail_ordinary_at) { - watching = false; - throw std::bad_alloc(); - } - Observe(size); - } - return __real__Znwm(size); -} -#endif - -namespace paimon::test { -namespace { -constexpr uint64_t kAmpleBudget = 32 * 1024 * 1024; - -#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && \ - _GLIBCXX_RELEASE >= 9 && PAIMON_BOUNDED_PLANNING_ZSTD -std::string ReadText(const std::string& path) { - std::ifstream input(path, std::ios::binary); - return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; -} -void WriteText(const std::string& path, const std::string& text) { - std::ofstream out(path, std::ios::binary | std::ios::trunc); - out.write(text.data(), text.size()); - ASSERT_TRUE(out.good()); -} -std::string Fixture(const std::string& format) { - return GetDataDir() + (format == "orc" - ? "/orc/append_types_compatibility.db/java_time_types" - : "/parquet/append_types_compatibility.db/rust_time_types"); -} -Result> Context( - const std::string& path, const std::shared_ptr& pool = GetDefaultPool(), - const std::map& options = {}, - const std::optional& schema_override = std::nullopt) { - ScanContextBuilder builder(path); - builder.SetTableSchema(schema_override.value_or(ReadText(path + "/schema/schema-0"))) - .WithMemoryPool(pool) - .AddOption("scan.snapshot-id", "1"); - for (const auto& [key, value] : options) { - builder.AddOption(key, value); - } - return builder.Finish(); -} -Result> BoundedPlan(const std::string& path, - const std::shared_ptr& budget) { - PAIMON_ASSIGN_OR_RAISE(std::unique_ptr context, - Context(path, PlanningBudgetAccess::Upstream(budget))); - PAIMON_ASSIGN_OR_RAISE(std::unique_ptr scan, - TableScan::CreateWithPlanningBudget(std::move(context), budget)); - return scan->CreatePlan(); -} -Result> ReadSplits( - const std::string& path, const std::vector>& splits) { - ReadContextBuilder builder(path); - builder.SetTableSchema(ReadText(path + "/schema/schema-0")); - if (path.find("/orc/") != std::string::npos) { - // Existing ORC TIME decoding is unsupported. Exercise its native projected - // data-reader path without expanding this planning change into type fixes. - builder.SetReadFieldNames({"id"}); - } - PAIMON_ASSIGN_OR_RAISE(std::unique_ptr context, builder.Finish()); - PAIMON_ASSIGN_OR_RAISE(std::unique_ptr table, TableRead::Create(std::move(context))); - PAIMON_ASSIGN_OR_RAISE(std::unique_ptr reader, table->CreateReader(splits)); - std::vector> arrays; - while (true) { - PAIMON_ASSIGN_OR_RAISE(BatchReader::ReadBatch batch, reader->NextBatch()); - if (BatchReader::IsEofBatch(batch)) { - break; - } - auto imported = arrow::ImportArray(batch.first.get(), batch.second.get()); - if (!imported.ok()) { - reader->Close(); - return Status::Invalid("Cannot import planned data"); - } - arrays.push_back(std::move(imported).ValueOrDie()); - } - reader->Close(); - auto combined = arrow::Concatenate(arrays); - if (!combined.ok()) { - return Status::Invalid("Cannot concatenate planned data"); - } - return std::move(combined).ValueOrDie(); -} - -// Deterministic independent OCF input writer: production never calls this encoder. -void Long(int64_t value, std::string* out) { - uint64_t bits = (static_cast(value) << 1) ^ static_cast(value >> 63); - while (bits >= 128) { - out->push_back(static_cast((bits & 127) | 128)); - bits >>= 7; - } - out->push_back(static_cast(bits)); -} -void Bytes(std::string_view bytes, std::string* out) { - Long(bytes.size(), out); - out->append(bytes); -} -std::string Ocf(std::string_view schema, const std::string& records, int64_t count, - const std::string& codec = "null", bool unknown_size = false) { - const std::string sync = "0123456789abcdef"; - std::string out("Obj\1", 4); - Long(2, &out); - Bytes("avro.schema", &out); - Bytes(schema, &out); - Bytes("avro.codec", &out); - Bytes(codec, &out); - Long(0, &out); - out += sync; - if (count != 0) { - std::string block = records; - if (codec == "zstandard") { - block.resize(ZSTD_compressBound(records.size())); - auto* compressor = ZSTD_createCCtx(); - EXPECT_NE(compressor, nullptr); - EXPECT_FALSE(ZSTD_isError( - ZSTD_CCtx_setParameter(compressor, ZSTD_c_contentSizeFlag, unknown_size ? 0 : 1))); - size_t size = ZSTD_compress2(compressor, block.data(), block.size(), records.data(), - records.size()); - ZSTD_freeCCtx(compressor); - EXPECT_FALSE(ZSTD_isError(size)); - block.resize(size); - } - Long(count, &out); - Bytes(block, &out); - out += sync; - } - return out; -} -constexpr const char* kListSchema = - R"({"type":"record","name":"List","fields":[{"name":"_VERSION","type":"int"},{"name":"_FILE_NAME","type":"string"}]})"; -constexpr const char* kEntrySchema = - R"({"type":"record","name":"Entry","fields":[{"name":"_VERSION","type":"int"},{"name":"_KIND","type":"int"},{"name":"_PARTITION","type":"bytes"},{"name":"_BUCKET","type":"int"},{"name":"_TOTAL_BUCKETS","type":"int"},{"name":"_FILE","type":{"type":"record","name":"File","fields":[{"name":"_FILE_NAME","type":"string"},{"name":"_FILE_SIZE","type":"long"},{"name":"_ROW_COUNT","type":"long"},{"name":"_SCHEMA_ID","type":"long"},{"name":"_LEVEL","type":"int"},{"name":"_MIN_SEQUENCE_NUMBER","type":"long"},{"name":"_MAX_SEQUENCE_NUMBER","type":"long"}]}}]})"; -std::string Entry(int64_t kind = 0, std::string_view filename = "data.parquet") { - std::string row; - Long(2, &row); - Long(kind, &row); - Bytes(std::string(12, '\0'), &row); - Long(0, &row); - Long(-1, &row); - Bytes(filename, &row); - for (int64_t field : {10, 3, 0, 0, 0, 2}) { - Long(field, &row); - } - return row; -} -void Synthetic(const std::string& path, const std::string& records, int64_t count, - const std::string& codec = "null", bool unknown_size = false) { - WriteText( - path + "/snapshot/snapshot-1", - R"({"id":1,"schemaId":0,"baseManifestList":"bounded-base","deltaManifestList":"bounded-delta"})"); - WriteText(path + "/manifest/bounded-base", Ocf(kListSchema, "", 0)); - std::string list; - Long(2, &list); - Bytes("bounded-manifest", &list); - WriteText(path + "/manifest/bounded-delta", Ocf(kListSchema, list, 1)); - WriteText(path + "/manifest/bounded-manifest", - Ocf(kEntrySchema, records, count, codec, unknown_size)); -} - -class FaultPool : public MemoryPool { - public: - void* Malloc(uint64_t size, uint64_t alignment) override { - if (calls++ == fail_at) { - throw std::bad_alloc(); - } -#ifdef PAIMON_TEST_PLANNING_ALLOCATIONS - Observe(size); -#endif - void* p = upstream->Malloc(size, alignment); - live += size; - peak = std::max(live, peak); - return p; - } - void* Realloc(void*, size_t, size_t, uint64_t) override { - throw std::bad_alloc(); - } - void Free(void* p, uint64_t size) override { - upstream->Free(p, size); - live -= size; - } - uint64_t CurrentUsage() const override { - return live; - } - uint64_t MaxMemoryUsage() const override { - return peak; - } - std::shared_ptr upstream = GetDefaultPool(); - uint64_t calls = 0, live = 0, peak = 0; - uint64_t fail_at = UINT64_MAX; -}; -#endif -} // namespace - -TEST(PlanningBudgetTest, ExactAdmissionOverflowAndNonReplenishingFree) { - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(8)); - void* allocation = PlanningBudgetAccess::Allocate(budget, 8, 8); - PlanningBudgetAccess::Free(budget, allocation, 8, 8); - EXPECT_EQ(budget->BytesAdmitted(), 8); - EXPECT_THROW((void)PlanningBudgetAccess::Allocate(budget, 1, 8), std::bad_alloc); - EXPECT_THROW((void)PlanningBudgetAccess::Allocate(budget, 0, 8), std::bad_alloc); - EXPECT_TRUE(PlanningBudgetAccess::Failure(budget).IsCapacityError()); - ASSERT_OK_AND_ASSIGN(auto huge, PlanningBudget::Create(UINT64_MAX)); - PlanningBudgetAccess::Reserve(huge, UINT64_MAX); - EXPECT_THROW(PlanningBudgetAccess::Reserve(huge, 1), std::bad_alloc); - EXPECT_EQ(huge->BytesAdmitted(), UINT64_MAX); - EXPECT_FALSE(PlanningBudget::Create(1, nullptr).ok()); -} -TEST(PlanningBudgetTest, SharedConcurrentAdmissionCannotOversubscribe) { - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(1000)); - std::atomic accepted{0}; - std::vector workers; - for (int32_t i = 0; i < 8; ++i) { - workers.emplace_back([&] { - for (int32_t j = 0; j < 200; ++j) { - try { - PlanningBudgetAccess::Reserve(budget, 1); - ++accepted; - } catch (const std::bad_alloc&) { - break; - } - } - }); - } - for (auto& worker : workers) { - worker.join(); - } - EXPECT_EQ(accepted, 1000); - EXPECT_EQ(budget->BytesAdmitted(), 1000); -} - -TEST(PlanningBudgetTest, SharedAllocationAndCrossThreadFreeMayOverlap) { - class RendezvousPool : public MemoryPool { - public: - void* Malloc(uint64_t size, uint64_t alignment) override { - Rendezvous(); - return upstream->Malloc(size, alignment); - } - void Free(void* p, uint64_t size) override { - Rendezvous(); - upstream->Free(p, size); - } - void* Realloc(void* p, size_t old_size, size_t new_size, uint64_t alignment) override { - return upstream->Realloc(p, old_size, new_size, alignment); - } - uint64_t CurrentUsage() const override { - return upstream->CurrentUsage(); - } - uint64_t MaxMemoryUsage() const override { - return upstream->MaxMemoryUsage(); - } - void Rendezvous() { - if (!overlap) { - return; - } - std::unique_lock lock(mutex); - ++arrivals; - ready.notify_all(); - timed_out |= - !ready.wait_for(lock, std::chrono::seconds(5), [&] { return arrivals == 2; }); - } - // Same implementation as GetDefaultPool(), with isolated usage counters. - std::unique_ptr upstream = GetMemoryPool(); - bool overlap = false, timed_out = false; - uint32_t arrivals = 0; - std::mutex mutex; - std::condition_variable ready; - }; - auto pool = std::make_shared(); - constexpr uint64_t kBytes = 8; - constexpr uint64_t kIterations = 1000; - constexpr uint64_t kWorkers = 8; - constexpr uint64_t kLimit = (kWorkers * kIterations + 2) * kBytes; - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kLimit, pool)); - void* escaped = PlanningBudgetAccess::Allocate(budget, kBytes, kBytes); - void* allocated = nullptr; - pool->overlap = true; - std::thread allocating( - [&] { allocated = PlanningBudgetAccess::Allocate(budget, kBytes, kBytes); }); - std::thread freeing([&] { PlanningBudgetAccess::Free(budget, escaped, kBytes, kBytes); }); - allocating.join(); - freeing.join(); - pool->overlap = false; - EXPECT_FALSE(pool->timed_out); - EXPECT_EQ(pool->arrivals, 2); - PlanningBudgetAccess::Free(budget, allocated, kBytes, kBytes); - - std::vector workers; - for (uint64_t i = 0; i < kWorkers; ++i) { - workers.emplace_back([&] { - for (uint64_t j = 0; j < kIterations; ++j) { - void* p = PlanningBudgetAccess::Allocate(budget, kBytes, kBytes); - PlanningBudgetAccess::Free(budget, p, kBytes, kBytes); - } - }); - } - for (auto& worker : workers) { - worker.join(); - } - EXPECT_EQ(budget->BytesAdmitted(), kLimit); - EXPECT_EQ(pool->CurrentUsage(), 0); - EXPECT_NO_THROW(PlanningBudgetAccess::Reserve(budget, 0)); -} - -#if defined(__linux__) && defined(__GLIBCXX__) && _GLIBCXX_USE_CXX11_ABI && \ - _GLIBCXX_RELEASE >= 9 && PAIMON_BOUNDED_PLANNING_ZSTD -#ifdef PAIMON_TEST_PLANNING_ALLOCATIONS -TEST(BoundedTableScanTest, CalibrateStringAllocationObservationAndInjection) { - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); - PlanningBudgetAccess::Reserve(budget, 2049); - requested = 0; - ordinary_allocations = 0; - observed_overdraft = false; - observed_budget = budget.get(); - watching = true; - std::string value(2048, 'x'); - watching = false; - EXPECT_EQ(value.size(), 2048); - EXPECT_EQ(requested, 2049); - EXPECT_EQ(ordinary_allocations, 1); - ordinary_allocations = 0; - fail_ordinary_at = 0; - watching = true; - EXPECT_THROW((void)std::string(2048, 'x'), std::bad_alloc); - watching = false; - fail_ordinary_at = UINT64_MAX; -} - -TEST(BoundedTableScanTest, ObserveRealAllocationRequestsAndFailEveryPublicationAllocation) { - // Initialize fixed process-owned constants before observing query payload. - (void)BinaryRow::EmptyRow(); - (void)SimpleStats::EmptyStats(); - auto path = Fixture("parquet"); - auto pool = std::make_shared(); - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget, pool)); - ASSERT_OK_AND_ASSIGN(std::unique_ptr context, Context(path, pool)); - requested = 0; - ordinary_allocations = 0; - observed_overdraft = false; - observed_budget = budget.get(); - watching = true; - auto scan = TableScan::CreateWithPlanningBudget(std::move(context), budget); - // Retained input was constructed before observation. Consume all factory - // admission before planning so that those credits cannot hide a missing - // reservation in a later native publication constructor. - if (scan.ok()) { - requested = budget->BytesAdmitted(); - } - auto plan = - scan.ok() ? scan.value()->CreatePlan() : Result>(scan.status()); - watching = false; - ASSERT_OK(plan); - EXPECT_GT(requested, 0); - EXPECT_GT(ordinary_allocations, 0); - EXPECT_LE(requested, budget->BytesAdmitted()); - EXPECT_FALSE(observed_overdraft); - const uint64_t publications = ordinary_allocations; - std::move(plan).value().reset(); - std::move(scan).value().reset(); - EXPECT_EQ(pool->live, 0); - for (uint64_t allocation = 0; allocation < publications; ++allocation) { - SCOPED_TRACE(allocation); - auto faulty_pool = std::make_shared(); - ASSERT_OK_AND_ASSIGN(auto faulty_budget, PlanningBudget::Create(kAmpleBudget, faulty_pool)); - ASSERT_OK_AND_ASSIGN(auto faulty_context, Context(path, faulty_pool)); - requested = 0; - ordinary_allocations = 0; - fail_ordinary_at = allocation; - observed_budget = faulty_budget.get(); - watching = true; - auto faulty_scan = - TableScan::CreateWithPlanningBudget(std::move(faulty_context), faulty_budget); - auto failed = faulty_scan.ok() ? faulty_scan.value()->CreatePlan() - : Result>(faulty_scan.status()); - watching = false; - fail_ordinary_at = UINT64_MAX; - ASSERT_FALSE(failed.ok()); - EXPECT_TRUE(failed.status().IsOutOfMemory()); - EXPECT_EQ(faulty_pool->live, 0); - } -} -#endif - -TEST(BoundedTableScanTest, RealParquetAndOrcPlanReadParityAndEscapedSplit) { - for (const std::string format : {"parquet", "orc"}) { - SCOPED_TRACE(format); - auto path = Fixture(format); - ASSERT_OK_AND_ASSIGN(std::unique_ptr context, Context(path)); - ASSERT_OK_AND_ASSIGN(auto ordinary, TableScan::Create(std::move(context))); - ASSERT_OK_AND_ASSIGN(auto baseline, ordinary->CreatePlan()); - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); - ASSERT_OK_AND_ASSIGN(auto bounded, BoundedPlan(path, budget)); - ASSERT_EQ(bounded->SnapshotId(), baseline->SnapshotId()); - ASSERT_FALSE(bounded->Splits().empty()); - std::vector actual, expected; - for (const auto& split : bounded->Splits()) { - auto files = std::dynamic_pointer_cast(split)->GetFileList(); - actual.insert(actual.end(), files.begin(), files.end()); - } - for (const auto& split : baseline->Splits()) { - auto files = std::dynamic_pointer_cast(split)->GetFileList(); - expected.insert(expected.end(), files.begin(), files.end()); - } - ASSERT_EQ(actual.size(), expected.size()); - for (size_t i = 0; i < actual.size(); ++i) { - EXPECT_EQ(actual[i].file_path, expected[i].file_path); - EXPECT_EQ(actual[i].row_count, expected[i].row_count); - EXPECT_EQ(actual[i].file_size, expected[i].file_size); - EXPECT_EQ(actual[i].schema_id, expected[i].schema_id); - } - ASSERT_OK_AND_ASSIGN(auto actual_rows, ReadSplits(path, bounded->Splits())); - ASSERT_OK_AND_ASSIGN(auto expected_rows, ReadSplits(path, baseline->Splits())); - EXPECT_EQ(actual_rows->length(), format == "orc" ? 3 : 2); - EXPECT_TRUE(actual_rows->Equals(expected_rows)); - auto ids = std::static_pointer_cast( - std::static_pointer_cast(actual_rows)->GetFieldByName("id")); - ASSERT_TRUE(ids); - for (int64_t row = 0; row < ids->length(); ++row) { - EXPECT_FALSE(ids->IsNull(row)); - EXPECT_EQ(ids->Value(row), row + 1); - } - EXPECT_LE(budget->BytesAdmitted(), budget->ByteLimit()); - auto split = bounded->Splits().front(); - std::weak_ptr weak = budget; - bounded.reset(); - budget.reset(); - EXPECT_FALSE(weak.expired()); - ASSERT_OK_AND_ASSIGN(auto escaped_rows, ReadSplits(path, {split})); - EXPECT_TRUE(escaped_rows->Equals(expected_rows)); - split.reset(); - EXPECT_TRUE(weak.expired()); - } -} -TEST(BoundedTableScanTest, ExactSuccessfulLimitAndOneByteShortFailClosed) { - auto path = Fixture("parquet"); - ASSERT_OK_AND_ASSIGN(auto initial, PlanningBudget::Create(kAmpleBudget)); - ASSERT_OK(BoundedPlan(path, initial)); - auto needed = initial->BytesAdmitted(); - ASSERT_GT(needed, 0); - ASSERT_OK_AND_ASSIGN(auto exact, PlanningBudget::Create(needed)); - ASSERT_OK(BoundedPlan(path, exact)); - ASSERT_EQ(exact->BytesAdmitted(), needed); - ASSERT_OK_AND_ASSIGN(auto short_budget, PlanningBudget::Create(needed - 1)); - auto failed = BoundedPlan(path, short_budget); - ASSERT_FALSE(failed.ok()); - EXPECT_TRUE(failed.status().IsCapacityError()); - EXPECT_LE(short_budget->BytesAdmitted(), needed - 1); - auto retry = BoundedPlan(path, short_budget); - EXPECT_FALSE(retry.ok()); - ASSERT_OK_AND_ASSIGN(auto zero, PlanningBudget::Create(0)); - EXPECT_TRUE(BoundedPlan(path, zero).status().IsCapacityError()); -} -TEST(BoundedTableScanTest, AllocationFailureAtEveryArenaAllocationReleasesEverything) { - auto path = Fixture("parquet"); - auto successful_pool = std::make_shared(); - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget, successful_pool)); - { - ASSERT_OK_AND_ASSIGN(auto plan, BoundedPlan(path, budget)); - EXPECT_FALSE(plan->Splits().empty()); - } - auto allocation_count = successful_pool->calls; - EXPECT_EQ(successful_pool->live, 0); - ASSERT_GT(allocation_count, 10); - for (uint64_t allocation = 0; allocation < allocation_count; ++allocation) { - SCOPED_TRACE(allocation); - auto pool = std::make_shared(); - pool->fail_at = allocation; - ASSERT_OK_AND_ASSIGN(auto limited, PlanningBudget::Create(kAmpleBudget, pool)); - auto plan = BoundedPlan(path, limited); - ASSERT_FALSE(plan.ok()); - EXPECT_TRUE(plan.status().IsOutOfMemory()); - EXPECT_EQ(pool->live, 0); - EXPECT_LE(limited->BytesAdmitted(), limited->ByteLimit()); - } -} -TEST(BoundedTableScanTest, RefusesUnsupportedProfileAndIdentityRatherThanFallingBack) { - auto path = Fixture("parquet"); - for (const auto& options : - std::vector>{{{"bucket", "1"}}, - {{"branch", "other"}}, - {{"scan.mode", "latest"}}, - {{"scan.snapshot-id", "LATEST"}}, - {{"data-evolution.enabled", "true"}}, - {{"deletion-vectors.enabled", "true"}}, - {{"manifest.format", "orc"}}}) { - ASSERT_OK_AND_ASSIGN(std::unique_ptr context, - Context(path, GetDefaultPool(), options)); - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); - ASSERT_OK_AND_ASSIGN(std::unique_ptr scan, - TableScan::CreateWithPlanningBudget(std::move(context), budget)); - auto first = scan->CreatePlan(); - ASSERT_FALSE(first.ok()); - auto second = scan->CreatePlan(); - ASSERT_FALSE(second.ok()); - EXPECT_EQ(first.status().ToString(), second.status().ToString()); - } - ASSERT_OK_AND_ASSIGN(std::unique_ptr context, Context(path)); - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget, GetMemoryPool())); - EXPECT_FALSE(TableScan::CreateWithPlanningBudget(std::move(context), budget).ok()); - auto directory = UniqueTestDirectory::Create(); - ASSERT_TRUE(directory); - ASSERT_TRUE(TestUtil::CopyDirectory(path, directory->Str())); - WriteText(directory->Str() + "/snapshot/snapshot-1", R"({"id":2,"schemaId":0})"); - ASSERT_OK_AND_ASSIGN(auto identity, PlanningBudget::Create(kAmpleBudget)); - EXPECT_FALSE(BoundedPlan(directory->Str(), identity).ok()); -} -TEST(BoundedTableScanTest, OcfNullZstdDuplicateDeleteAndLateCorruption) { - auto directory = UniqueTestDirectory::Create(); - ASSERT_TRUE(directory); - ASSERT_TRUE(TestUtil::CopyDirectory(Fixture("parquet"), directory->Str())); - auto path = directory->Str(); - for (const auto& [codec, unknown] : std::vector>{ - {"null", false}, {"zstandard", false}, {"zstandard", true}}) { - Synthetic(path, Entry(), 1, codec, unknown); - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); - ASSERT_OK_AND_ASSIGN(auto plan, BoundedPlan(path, budget)); - ASSERT_EQ(plan->Splits().size(), 1); - } - for (const auto& [records, count] : std::vector>{ - {Entry() + Entry(), 2}, {Entry(1), 1}, {Entry() + Entry().substr(0, 3), 2}}) { - Synthetic(path, records, count); - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); - EXPECT_FALSE(BoundedPlan(path, budget).ok()); - } - Synthetic(path, Entry(), 1); - auto valid = ReadText(path + "/manifest/bounded-manifest"); - for (size_t length : {static_cast(0), static_cast(4), valid.size() - 1}) { - WriteText(path + "/manifest/bounded-manifest", valid.substr(0, length)); - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); - EXPECT_FALSE(BoundedPlan(path, budget).ok()); - } - valid.back() ^= 1; - WriteText(path + "/manifest/bounded-manifest", valid); - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); - EXPECT_FALSE(BoundedPlan(path, budget).ok()); -} -TEST(BoundedTableScanTest, RejectMalformedWriterTypesAndDuplicateFields) { - auto directory = UniqueTestDirectory::Create(); - ASSERT_TRUE(directory); - ASSERT_TRUE(TestUtil::CopyDirectory(Fixture("parquet"), directory->Str())); - auto path = directory->Str(); - for (const std::string field : {"_DELETE_ROW_COUNT", "_EXTERNAL_PATH", "_FILE_SOURCE"}) { - for (const std::string type : {"float", "double", "boolean"}) { - SCOPED_TRACE(field + type); - Synthetic(path, Entry(), 1); - std::string schema = kEntrySchema; - auto position = schema.rfind("]}}"); - ASSERT_NE(position, std::string::npos); - schema.insert(position, R"(,{"name":")" + field + R"(","type":")" + type + R"("})"); - std::string row = Entry(); - row.append(type == "double" ? 8 : type == "float" ? 4 : 1, '\0'); - WriteText(path + "/manifest/bounded-manifest", Ocf(schema, row, 1)); - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); - EXPECT_FALSE(BoundedPlan(path, budget).ok()); - } - } - Synthetic(path, Entry(), 1); - std::string schema = kEntrySchema; - schema.insert(schema.rfind("]}}"), - "," + std::string(R"({"name":"_FILE_NAME","type":"string"})")); - std::string row = Entry(); - Bytes("other.parquet", &row); - WriteText(path + "/manifest/bounded-manifest", Ocf(schema, row, 1)); - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); - EXPECT_FALSE(BoundedPlan(path, budget).ok()); -} - -TEST(BoundedTableScanTest, RejectEmptyWrongTypesAndJsonAmbiguity) { - auto directory = UniqueTestDirectory::Create(); - ASSERT_TRUE(directory); - ASSERT_TRUE(TestUtil::CopyDirectory(Fixture("parquet"), directory->Str())); - auto path = directory->Str(); - for (const std::string type : - {R"({"type":"array","items":"string"})", R"({"type":"map","values":"string"})", - R"({"type":"fixed","name":"NotAPath","size":0})", R"("bytes")", R"("string")"}) { - Synthetic(path, Entry(), 1); - std::string schema = kEntrySchema; - schema.insert(schema.rfind("]}}"), R"(,{"name":"_EXTERNAL_PATH","type":)" + type + "}"); - std::string row = Entry(); - if (type.find("fixed") == std::string::npos) { - Long(0, &row); - } - WriteText(path + "/manifest/bounded-manifest", Ocf(schema, row, 1)); - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); - EXPECT_FALSE(BoundedPlan(path, budget).ok()); - } - Synthetic(path, Entry(), 1); - WriteText( - path + "/snapshot/snapshot-1", - R"({"id":1,"id":2,"schemaId":0,"baseManifestList":"bounded-base","deltaManifestList":"bounded-delta"})"); - ASSERT_OK_AND_ASSIGN(auto duplicate, PlanningBudget::Create(kAmpleBudget)); - EXPECT_FALSE(BoundedPlan(path, duplicate).ok()); - std::string huge_name(8 * 1024 * 1024, 'a'); - huge_name += ".parquet"; - Synthetic(path, Entry(0, huge_name), 1, "zstandard"); - ASSERT_OK_AND_ASSIGN(auto small, PlanningBudget::Create(4 * 1024 * 1024)); - auto exhausted = BoundedPlan(path, small); - ASSERT_FALSE(exhausted.ok()); - EXPECT_TRUE(exhausted.status().IsCapacityError()); - EXPECT_LE(small->BytesAdmitted(), small->ByteLimit()); -} - -TEST(BoundedTableScanTest, RejectNonRecordEmptyWriterAndFixedFileName) { - auto directory = UniqueTestDirectory::Create(); - ASSERT_TRUE(directory); - ASSERT_TRUE(TestUtil::CopyDirectory(Fixture("parquet"), directory->Str())); - const auto path = directory->Str(); - Synthetic(path, Entry(), 1); - std::string bad_root = kListSchema; - const auto root_type = bad_root.find("record"); - ASSERT_NE(root_type, std::string::npos); - bad_root.replace(root_type, 6, "int"); - WriteText(path + "/manifest/bounded-base", Ocf(bad_root, "", 0)); - ASSERT_OK_AND_ASSIGN(auto non_record, PlanningBudget::Create(kAmpleBudget)); - EXPECT_FALSE(BoundedPlan(path, non_record).ok()); - - Synthetic(path, Entry(), 1); - std::string schema = kEntrySchema; - const std::string original = R"({"name":"_FILE_NAME","type":"string"})"; - const auto field = schema.find(original); - ASSERT_NE(field, std::string::npos); - schema.replace(field, original.size(), - R"({"name":"_FILE_NAME","type":{"type":"fixed","name":"FixedName","size":12}})"); - std::string row = Entry(); - auto name = row.find("data.parquet"); - ASSERT_NE(name, std::string::npos); - ASSERT_GT(name, 0); - row.erase(name - 1, 1); // fixed has no string-length prefix. - WriteText(path + "/manifest/bounded-manifest", Ocf(schema, row, 1)); - ASSERT_OK_AND_ASSIGN(auto fixed_name, PlanningBudget::Create(kAmpleBudget)); - EXPECT_FALSE(BoundedPlan(path, fixed_name).ok()); -} - -#else -TEST(BoundedTableScanTest, UnqualifiedPlatformFailsExplicitly) { - ASSERT_OK_AND_ASSIGN(auto budget, PlanningBudget::Create(kAmpleBudget)); - auto result = TableScan::CreateWithPlanningBudget(nullptr, budget); - EXPECT_FALSE(result.ok()); - EXPECT_TRUE(result.status().IsNotImplemented()); - - const std::string path = - GetDataDir() + "/parquet/append_types_compatibility.db/rust_time_types"; - std::ifstream input(path + "/schema/schema-0", std::ios::binary); - ASSERT_TRUE(input.good()); - const std::string schema{std::istreambuf_iterator(input), - std::istreambuf_iterator()}; - ScanContextBuilder builder(path); - builder.SetTableSchema(schema).AddOption("scan.snapshot-id", "1"); - ASSERT_OK_AND_ASSIGN(auto context, builder.Finish()); - ASSERT_OK_AND_ASSIGN(auto ordinary, TableScan::Create(std::move(context))); - ASSERT_OK_AND_ASSIGN(auto plan, ordinary->CreatePlan()); - EXPECT_FALSE(plan->Splits().empty()); -} -#endif -} // namespace paimon::test diff --git a/src/paimon/core/table/source/planning_memory_pool_test.cpp b/src/paimon/core/table/source/planning_memory_pool_test.cpp new file mode 100644 index 000000000..c8b2fed50 --- /dev/null +++ b/src/paimon/core/table/source/planning_memory_pool_test.cpp @@ -0,0 +1,402 @@ +/* + * 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "arrow/memory_pool.h" +#include "gtest/gtest.h" +#include "paimon/common/io/cache/lru_cache.h" +#include "paimon/common/utils/arrow/mem_utils.h" +#include "paimon/executor.h" +#include "paimon/fs/local/local_file_system.h" +#include "paimon/memory/memory_pool.h" +#include "paimon/scan_context.h" +#include "paimon/table/source/table_scan.h" +#include "paimon/testing/utils/testharness.h" + +namespace paimon::test { +namespace { +class InlinePlanningExecutor final : public Executor { + public: + void Add(std::function task) override { + task(); + } + void ShutdownNow() override {} + uint32_t GetThreadNum() const override { + return 1; + } +}; + +class PlanningFaultPool final : public MemoryPool { + public: + void* Malloc(uint64_t size, uint64_t alignment) override { + std::lock_guard lock(mutex_); + Check(); + ++malloc_calls; + return upstream_->Malloc(size, alignment); + } + void* Realloc(void* pointer, size_t old_size, size_t new_size, uint64_t alignment) override { + std::lock_guard lock(mutex_); + Check(); + ++realloc_calls; + return upstream_->Realloc(pointer, old_size, new_size, alignment); + } + void Free(void* pointer, uint64_t size) override { + std::lock_guard lock(mutex_); + upstream_->Free(pointer, size); + } + void Free(void* pointer, uint64_t size, uint64_t alignment) override { + std::lock_guard lock(mutex_); + upstream_->Free(pointer, size, alignment); + } + uint64_t CurrentUsage() const override { + return upstream_->CurrentUsage(); + } + uint64_t MaxMemoryUsage() const override { + return upstream_->MaxMemoryUsage(); + } + std::atomic calls{0}; + std::atomic fail_at{std::numeric_limits::max()}; + std::atomic malloc_calls{0}; + std::atomic realloc_calls{0}; + std::atomic rejected{false}; + + private: + void Check() { + if (calls.fetch_add(1) == fail_at.load()) { + rejected = true; + throw std::bad_alloc(); + } + } + std::unique_ptr upstream_ = GetMemoryPool(); + std::mutex mutex_; +}; + +std::string PlanningFixture() { + return GetDataDir() + "/parquet/append_types_compatibility.db/rust_time_types"; +} + +struct PlanningInput { + std::string path = PlanningFixture(); + std::string snapshot = "1"; + std::shared_ptr cache; + std::optional bucket; + std::shared_ptr file_system; +}; + +Result> OrdinaryScan( + const std::shared_ptr& pool, + const std::shared_ptr& executor = std::make_shared(), + const PlanningInput& input = {}) { + std::ifstream schema_file(input.path + "/schema/schema-0"); + std::string schema{std::istreambuf_iterator(schema_file), + std::istreambuf_iterator()}; + ScanContextBuilder builder(input.path); + builder.SetTableSchema(schema).WithMemoryPool(pool).WithExecutor(executor).AddOption( + "scan.snapshot-id", input.snapshot); + if (input.cache) { + builder.WithCache(input.cache) + .AddOption("scan.manifest-entry-cache.max-snapshots", "8") + .AddOption("scan.manifest-entry.lazy-decode.enabled", "true"); + } + if (input.bucket) builder.SetBucketFilter(*input.bucket); + if (input.file_system) builder.WithFileSystem(input.file_system); + PAIMON_ASSIGN_OR_RAISE(std::unique_ptr context, builder.Finish()); + return TableScan::Create(std::move(context)); +} + +class RejectingPlanningExecutor final : public Executor { + public: + explicit RejectingPlanningExecutor(uint32_t rejected_submission = 2, + uint32_t blocked_submission = 0) + : rejected_submission_(rejected_submission), + blocked_submission_(blocked_submission ? blocked_submission : rejected_submission - 1) {} + ~RejectingPlanningExecutor() override { + Release(false); + worker_.reset(); + } + void Add(std::function task) override { + uint32_t submission = ++submissions_; + if (submission == rejected_submission_) { + rejected_ = true; + throw std::bad_alloc(); + } + if (submission != blocked_submission_) { + worker_->Add([this, submission, task = std::move(task)] { + task(); + if (submission == 1) { + std::lock_guard lock(mutex_); + first_completed_ = true; + changed_.notify_all(); + } + }); + return; + } + worker_->Add([this, task = std::move(task)] { + bool execute; + { + std::unique_lock lock(mutex_); + blocked_ = true; + changed_.notify_all(); + changed_.wait(lock, [this] { return released_; }); + execute = execute_; + } + if (execute) task(); + }); + } + void ShutdownNow() override { + worker_->ShutdownNow(); + } + uint32_t GetThreadNum() const override { + return 2; + } + bool WaitUntilBlocked() { + std::unique_lock lock(mutex_); + return changed_.wait_for(lock, std::chrono::seconds(5), [this] { return blocked_; }); + } + void Release(bool execute) { + std::lock_guard lock(mutex_); + execute_ = execute; + released_ = true; + changed_.notify_all(); + } + bool Rejected() const { + return rejected_; + } + bool WaitUntilFirstCompleted() { + std::unique_lock lock(mutex_); + return changed_.wait_for(lock, std::chrono::seconds(5), + [this] { return first_completed_; }); + } + + private: + std::unique_ptr worker_ = CreateDefaultExecutor(2).value(); + std::atomic submissions_{0}; + uint32_t rejected_submission_; + uint32_t blocked_submission_; + std::atomic rejected_{false}; + std::mutex mutex_; + std::condition_variable changed_; + bool blocked_ = false; + bool released_ = false; + bool execute_ = false; + bool first_completed_ = false; +}; + +class PoolRefusingManifestFileSystem final : public LocalFileSystem { + public: + explicit PoolRefusingManifestFileSystem(std::shared_ptr pool) + : pool_(std::move(pool)) {} + using LocalFileSystem::Open; + Result> Open(const std::string& path) const override { + if (path.find("/manifest/") != std::string::npos && !refused_.exchange(true)) { + pool_->fail_at = pool_->calls.load(); + // A supplied file system may allocate through the caller's pool too. + // Force refusal inside the real manifest worker, before opening a file. + void* allocation = pool_->Malloc(1, 64); + pool_->Free(allocation, 1); + } + return LocalFileSystem::Open(path); + } + + private: + std::shared_ptr pool_; + mutable std::atomic refused_{false}; +}; +} // namespace + +TEST(OrdinaryPlanningPoolTest, RejectEachPlanningAllocationWithoutLeaking) { + auto baseline_pool = std::make_shared(); + auto scan_result = OrdinaryScan(baseline_pool); + ASSERT_TRUE(scan_result.ok()) << scan_result.status().ToString(); + auto baseline_scan = std::move(scan_result).value(); + RecordProperty("factory_pool_calls", baseline_pool->calls.load()); + baseline_pool->calls = 0; + baseline_pool->malloc_calls = 0; + baseline_pool->realloc_calls = 0; + auto baseline_result = baseline_scan->CreatePlan(); + ASSERT_TRUE(baseline_result.ok()) << baseline_result.status().ToString(); + auto baseline = std::move(baseline_result).value(); + ASSERT_FALSE(baseline->Splits().empty()); + const uint64_t allocation_count = baseline_pool->calls; + ASSERT_GT(allocation_count, 0); + RecordProperty("planning_malloc_calls", baseline_pool->malloc_calls.load()); + RecordProperty("planning_realloc_calls", baseline_pool->realloc_calls.load()); + baseline.reset(); + baseline_scan.reset(); + ASSERT_EQ(baseline_pool->CurrentUsage(), 0); + + for (uint64_t ordinal = 0; ordinal < allocation_count; ++ordinal) { + SCOPED_TRACE(ordinal); + auto pool = std::make_shared(); + auto result = OrdinaryScan(pool); + ASSERT_TRUE(result.ok()) << result.status().ToString(); + auto scan = std::move(result).value(); + pool->calls = 0; + pool->fail_at = ordinal; + bool failed = false; + try { + auto plan = scan->CreatePlan(); + failed = !plan.ok(); + } catch (const std::bad_alloc&) { + failed = true; + } + ASSERT_TRUE(pool->rejected); + ASSERT_TRUE(failed) << "allocation refusal returned a successful plan"; + scan.reset(); + EXPECT_EQ(pool->CurrentUsage(), 0) << "pool storage survived failed planning"; + pool->fail_at = std::numeric_limits::max(); + { + auto recovery_result = OrdinaryScan(pool); + ASSERT_TRUE(recovery_result.ok()) << recovery_result.status().ToString(); + auto recovery = std::move(recovery_result).value(); + auto plan = recovery->CreatePlan(); + ASSERT_TRUE(plan.ok()) << plan.status().ToString(); + EXPECT_FALSE(plan.value()->Splits().empty()); + } + EXPECT_EQ(pool->CurrentUsage(), 0); + } +} + +TEST(OrdinaryPlanningPoolTest, RetainedSplitRemainsUsableWithCallerOwnedPool) { + auto pool = std::make_shared(); + auto scan_result = OrdinaryScan(pool); + ASSERT_TRUE(scan_result.ok()) << scan_result.status().ToString(); + auto scan = std::move(scan_result).value(); + auto plan_result = scan->CreatePlan(); + ASSERT_TRUE(plan_result.ok()) << plan_result.status().ToString(); + auto plan = std::move(plan_result).value(); + ASSERT_FALSE(plan->Splits().empty()); + auto split = plan->Splits().front(); + auto original = Split::Serialize(split, pool); + ASSERT_TRUE(original.ok()) << original.status().ToString(); + scan.reset(); + EXPECT_EQ(plan->SnapshotId(), 1); + plan.reset(); + auto retained = Split::Serialize(split, pool); + ASSERT_TRUE(retained.ok()) << retained.status().ToString(); + EXPECT_EQ(original.value(), retained.value()); + split.reset(); + EXPECT_EQ(pool->CurrentUsage(), 0); +} + +TEST(OrdinaryPlanningPoolTest, RefusedArrowReallocationPreservesExistingBuffer) { + auto pool = std::make_shared(); + auto arrow_pool = GetArrowPool(pool); + uint8_t* data = nullptr; + ASSERT_TRUE(arrow_pool->Allocate(16, 64, &data).ok()); + ASSERT_NE(data, nullptr); + data[0] = 42; + uint8_t* original = data; + pool->fail_at = pool->calls.load(); + EXPECT_TRUE(arrow_pool->Reallocate(16, 32, 64, &data).IsOutOfMemory()); + EXPECT_TRUE(pool->rejected); + EXPECT_EQ(data, original); + EXPECT_EQ(data[0], 42); + EXPECT_EQ(pool->CurrentUsage(), 16); + arrow_pool->Free(data, 16, 64); + EXPECT_EQ(pool->CurrentUsage(), 0); +} + +TEST(OrdinaryPlanningPoolTest, SubmissionFailureDrainsAcceptedManifestTask) { + for (uint32_t phase = 0; phase < 3; ++phase) { + SCOPED_TRACE(phase); + PlanningInput input; + uint32_t rejected_submission = 2; + if (phase > 0) { + input.path = GetDataDir() + "/orc/append_09.db/append_09"; + input.snapshot = "5"; + rejected_submission = 4; + } + if (phase == 2) { + input.cache = std::make_shared(16 * 1024 * 1024); + input.bucket = 0; + } + auto pool = std::make_shared(); + auto executor = std::make_shared(rejected_submission); + auto result = OrdinaryScan(pool, executor, input); + ASSERT_TRUE(result.ok()) << result.status().ToString(); + auto scan = std::move(result).value(); + auto planning = std::async(std::launch::async, [&scan] { + try { + auto plan = scan->CreatePlan(); + return !plan.ok(); + } catch (const std::bad_alloc&) { + return true; + } + }); + bool blocked = executor->WaitUntilBlocked(); + bool returned_early = + planning.wait_for(std::chrono::milliseconds(200)) == std::future_status::ready; + // On the faulty path the task borrows an already unwound snapshot. Drop it + // without dereferencing that snapshot so the regression reports the ordering. + executor->Release(!returned_early); + EXPECT_TRUE(blocked); + EXPECT_TRUE(executor->Rejected()); + EXPECT_FALSE(returned_early) + << "CreatePlan returned with accepted manifest work still pending"; + EXPECT_TRUE(planning.get()); + scan.reset(); + executor.reset(); + input.cache.reset(); + EXPECT_EQ(pool->CurrentUsage(), 0); + } +} + +TEST(OrdinaryPlanningPoolTest, WorkerPoolExceptionDrainsAcceptedManifestTask) { + auto pool = std::make_shared(); + auto executor = std::make_shared(0, 2); + PlanningInput input; + input.file_system = std::make_shared(pool); + auto result = OrdinaryScan(pool, executor, input); + ASSERT_TRUE(result.ok()) << result.status().ToString(); + auto scan = std::move(result).value(); + auto planning = std::async(std::launch::async, [&scan] { + try { + (void)scan->CreatePlan(); + return false; + } catch (const std::bad_alloc&) { + return true; + } + }); + bool blocked = executor->WaitUntilBlocked(); + bool first_completed = executor->WaitUntilFirstCompleted(); + bool returned_early = + planning.wait_for(std::chrono::milliseconds(200)) == std::future_status::ready; + executor->Release(!returned_early); + EXPECT_TRUE(blocked); + EXPECT_TRUE(first_completed); + EXPECT_TRUE(pool->rejected); + EXPECT_FALSE(returned_early) << "worker exception unwound before the other manifest task"; + EXPECT_TRUE(planning.get()); + scan.reset(); + executor.reset(); + input.file_system.reset(); + EXPECT_EQ(pool->CurrentUsage(), 0); +} +} // namespace paimon::test From 0633f2eb2fa3f72acf9dc233e994e6dbe525ecbe Mon Sep 17 00:00:00 2001 From: Aaron Wu Date: Tue, 3 Nov 2026 01:02:53 +0800 Subject: [PATCH 07/11] docs(planning): clarify executor and cache pool lifetimes --- docs/source/user_guide/read.rst | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/source/user_guide/read.rst b/docs/source/user_guide/read.rst index b07cf0959..6c636361f 100644 --- a/docs/source/user_guide/read.rst +++ b/docs/source/user_guide/read.rst @@ -77,10 +77,20 @@ the pool alive. Use a thread-safe pool, or synchronize all its operations includ cleanup, when planning tasks or consumers can run concurrently. A scan itself must not be called concurrently. +Cache entries remain charged to the pool that allocated them until they are +released. A shared cache can retain allocations from multiple pools; keep each +such pool alive until all entries using its allocations have been released. + When ordinary manifest planning fails, it waits for already accepted metadata -tasks before unwinding the task submission scope. This does not cancel native -I/O or force an application executor to discard completed task closures; their -retained allocations remain subject to the pool lifetime rule above. +tasks before unwinding the task submission scope. For this guarantee, if +``Executor::Add`` throws, it must not have accepted or run the submitted task. +The default executor satisfies this condition. + +Waiting for a future to become ready does not guarantee that its task closure +has been destroyed. Destroying the default executor from a caller thread waits +for its workers to finish. With a shared or custom executor, ensure task closures +and their retained results have been released before releasing the pool. +Waiting for tasks does not cancel native I/O. Schema Evolution ----------------------- From 9d6ee7393da09db088a329a106afb40889715911 Mon Sep 17 00:00:00 2001 From: Aaron Wu Date: Tue, 3 Nov 2026 01:07:21 +0800 Subject: [PATCH 08/11] docs(planning): require task closure release before pool cleanup --- docs/source/user_guide/read.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/user_guide/read.rst b/docs/source/user_guide/read.rst index 6c636361f..61286f045 100644 --- a/docs/source/user_guide/read.rst +++ b/docs/source/user_guide/read.rst @@ -87,9 +87,9 @@ tasks before unwinding the task submission scope. For this guarantee, if The default executor satisfies this condition. Waiting for a future to become ready does not guarantee that its task closure -has been destroyed. Destroying the default executor from a caller thread waits -for its workers to finish. With a shared or custom executor, ensure task closures -and their retained results have been released before releasing the pool. +has been destroyed. Keep the pool alive until task closures and their retained +results have been released, including when a shared or custom executor outlives +the scan. Waiting for tasks does not cancel native I/O. Schema Evolution From 77eb191896aed3437b95c9576e207c2c27bd955b Mon Sep 17 00:00:00 2001 From: Aaron Wu Date: Tue, 3 Nov 2026 14:02:25 +0800 Subject: [PATCH 09/11] refactor(planning): address scan exception safety review --- docs/source/user_guide/read.rst | 65 +++++-------------- include/paimon/executor.h | 1 + src/paimon/CMakeLists.txt | 2 +- src/paimon/core/operation/file_store_scan.cpp | 12 +++- ...est.cpp => scan_exception_safety_test.cpp} | 62 +++++++----------- 5 files changed, 52 insertions(+), 90 deletions(-) rename src/paimon/core/table/source/{planning_memory_pool_test.cpp => scan_exception_safety_test.cpp} (84%) diff --git a/docs/source/user_guide/read.rst b/docs/source/user_guide/read.rst index 61286f045..dabeb5975 100644 --- a/docs/source/user_guide/read.rst +++ b/docs/source/user_guide/read.rst @@ -44,53 +44,24 @@ across the two language ecosystems. Planning with a custom memory pool ---------------------------------- -Use ``ScanContextBuilder::WithMemoryPool(pool)`` with ordinary -``TableScan::Create`` and ``CreatePlan`` to supply an application-owned pool. -Planning continues to use the normal metadata decoders, filters and caches. -A pool that refuses an allocation can throw ``std::bad_alloc``. The caller must -handle both an exception from the planning call and a failed ``Result``: adapters -such as the Arrow memory pool can translate allocation refusal into an error. -A failed call must not be treated as an empty or partial successful plan. - -The budget covers only requests routed through the supplied pool. STL containers, -metadata parser storage, shared services and other allocations may bypass it. -It is not a limit on total planning memory, data-reader memory or process RSS. -Unaccounted allocations are not guaranteed to be small merely because the pool -is bounded. Applications requiring a stronger bound need a separate policy and -validation of the uncovered allocations. - -The application chooses the accounting policy in its pool. A **live-byte** limit -charges outstanding requested bytes and returns credit on ``Free``. A growing -``Realloc`` must reserve the increase before requesting storage; on failure it -must preserve the original allocation and its charge. A **cumulative** limit -instead charges allocation work without refunding frees. These are different -contracts: freeing temporary buffers permits another allocation under a live-byte -limit, but not necessarily under a cumulative limit. Neither policy accounts for -allocations that do not reach that pool. ``MemoryPool::CurrentUsage`` describes -live usage; the scan API does not install either budget policy for the caller. - -Keep a custom pool alive until all objects using its allocations have been -released, including retained plans, separately retained splits, cache entries -and executor task closures. A plan or split does not automatically retain a -shared owner of every custom pool. It can outlive its scan when the caller keeps -the pool alive. Use a thread-safe pool, or synchronize all its operations including -cleanup, when planning tasks or consumers can run concurrently. A scan itself -must not be called concurrently. - -Cache entries remain charged to the pool that allocated them until they are -released. A shared cache can retain allocations from multiple pools; keep each -such pool alive until all entries using its allocations have been released. - -When ordinary manifest planning fails, it waits for already accepted metadata -tasks before unwinding the task submission scope. For this guarantee, if -``Executor::Add`` throws, it must not have accepted or run the submitted task. -The default executor satisfies this condition. - -Waiting for a future to become ready does not guarantee that its task closure -has been destroyed. Keep the pool alive until task closures and their retained -results have been released, including when a shared or custom executor outlives -the scan. -Waiting for tasks does not cancel native I/O. +Supply an application-owned pool through ``ScanContextBuilder::WithMemoryPool`` +while using the ordinary scan API: + +.. code-block:: cpp + + ScanContextBuilder builder(table_path); + builder.WithMemoryPool(pool); + PAIMON_ASSIGN_OR_RAISE(std::unique_ptr context, builder.Finish()); + PAIMON_ASSIGN_OR_RAISE(std::unique_ptr scan, TableScan::Create(std::move(context))); + PAIMON_ASSIGN_OR_RAISE(std::shared_ptr plan, scan->CreatePlan()); + +* The pool controls only allocations routed through it, not all planning memory + or process RSS. +* Handle both exceptions such as ``std::bad_alloc`` and failed ``Result`` values; + a failure is not an empty or partial successful plan. +* Keep the pool alive until all objects using its allocations have been released, + including retained plans, splits, cache entries and executor task closures. + Use a thread-safe pool or synchronize its operations when they can overlap. Schema Evolution ----------------------- diff --git a/include/paimon/executor.h b/include/paimon/executor.h index 7092af7b2..ba29e8436 100644 --- a/include/paimon/executor.h +++ b/include/paimon/executor.h @@ -54,6 +54,7 @@ class PAIMON_EXPORT Executor { /// /// @note This method should be thread-safe and can be called from multiple threads /// simultaneously. + /// If submission throws, the task must not have been accepted or run. virtual void Add(std::function func) = 0; /// Shutdown the executor immediately, discarding all pending tasks. diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt index 7726b7bad..069b029a6 100644 --- a/src/paimon/CMakeLists.txt +++ b/src/paimon/CMakeLists.txt @@ -1040,7 +1040,7 @@ if(PAIMON_BUILD_TESTS) core/table/source/snapshot/snapshot_reader_test.cpp core/table/source/startup_mode_test.cpp core/table/source/table_scan_test.cpp - core/table/source/planning_memory_pool_test.cpp + core/table/source/scan_exception_safety_test.cpp core/table/system/system_table_test.cpp core/tag/tag_test.cpp core/utils/blob_view_lookup_test.cpp diff --git a/src/paimon/core/operation/file_store_scan.cpp b/src/paimon/core/operation/file_store_scan.cpp index b6fbd1ced..d0bb04e47 100644 --- a/src/paimon/core/operation/file_store_scan.cpp +++ b/src/paimon/core/operation/file_store_scan.cpp @@ -326,7 +326,9 @@ Status FileStoreScan::ReadManifestsWithSnapshot(const Snapshot& snapshot, std::vector>>> futures; ScopeGuard drain([&futures] { for (auto& future : futures) { - if (future.valid()) future.wait(); + if (future.valid()) { + future.wait(); + } } }); futures.reserve(2); @@ -360,7 +362,9 @@ Status FileStoreScan::ReadFileEntries(const std::vector& manif std::vector>>> futures; ScopeGuard drain([&futures] { for (auto& future : futures) { - if (future.valid()) future.wait(); + if (future.valid()) { + future.wait(); + } } }); futures.reserve(manifest_metas.size()); @@ -525,7 +529,9 @@ Status FileStoreScan::ReadAndMergeBucketFileEntries( std::vector>>> futures; ScopeGuard drain([&futures] { for (auto& future : futures) { - if (future.valid()) future.wait(); + if (future.valid()) { + future.wait(); + } } }); futures.reserve(manifest_metas.size()); diff --git a/src/paimon/core/table/source/planning_memory_pool_test.cpp b/src/paimon/core/table/source/scan_exception_safety_test.cpp similarity index 84% rename from src/paimon/core/table/source/planning_memory_pool_test.cpp rename to src/paimon/core/table/source/scan_exception_safety_test.cpp index c8b2fed50..71a671de5 100644 --- a/src/paimon/core/table/source/planning_memory_pool_test.cpp +++ b/src/paimon/core/table/source/scan_exception_safety_test.cpp @@ -32,6 +32,7 @@ #include "gtest/gtest.h" #include "paimon/common/io/cache/lru_cache.h" #include "paimon/common/utils/arrow/mem_utils.h" +#include "paimon/common/utils/arrow/status_utils.h" #include "paimon/executor.h" #include "paimon/fs/local/local_file_system.h" #include "paimon/memory/memory_pool.h" @@ -230,18 +231,14 @@ class PoolRefusingManifestFileSystem final : public LocalFileSystem { }; } // namespace -TEST(OrdinaryPlanningPoolTest, RejectEachPlanningAllocationWithoutLeaking) { +TEST(ScanExceptionSafetyTest, RejectEachPlanningAllocationWithoutLeaking) { auto baseline_pool = std::make_shared(); - auto scan_result = OrdinaryScan(baseline_pool); - ASSERT_TRUE(scan_result.ok()) << scan_result.status().ToString(); - auto baseline_scan = std::move(scan_result).value(); + ASSERT_OK_AND_ASSIGN(auto baseline_scan, OrdinaryScan(baseline_pool)); RecordProperty("factory_pool_calls", baseline_pool->calls.load()); baseline_pool->calls = 0; baseline_pool->malloc_calls = 0; baseline_pool->realloc_calls = 0; - auto baseline_result = baseline_scan->CreatePlan(); - ASSERT_TRUE(baseline_result.ok()) << baseline_result.status().ToString(); - auto baseline = std::move(baseline_result).value(); + ASSERT_OK_AND_ASSIGN(auto baseline, baseline_scan->CreatePlan()); ASSERT_FALSE(baseline->Splits().empty()); const uint64_t allocation_count = baseline_pool->calls; ASSERT_GT(allocation_count, 0); @@ -254,9 +251,7 @@ TEST(OrdinaryPlanningPoolTest, RejectEachPlanningAllocationWithoutLeaking) { for (uint64_t ordinal = 0; ordinal < allocation_count; ++ordinal) { SCOPED_TRACE(ordinal); auto pool = std::make_shared(); - auto result = OrdinaryScan(pool); - ASSERT_TRUE(result.ok()) << result.status().ToString(); - auto scan = std::move(result).value(); + ASSERT_OK_AND_ASSIGN(auto scan, OrdinaryScan(pool)); pool->calls = 0; pool->fail_at = ordinal; bool failed = false; @@ -272,49 +267,42 @@ TEST(OrdinaryPlanningPoolTest, RejectEachPlanningAllocationWithoutLeaking) { EXPECT_EQ(pool->CurrentUsage(), 0) << "pool storage survived failed planning"; pool->fail_at = std::numeric_limits::max(); { - auto recovery_result = OrdinaryScan(pool); - ASSERT_TRUE(recovery_result.ok()) << recovery_result.status().ToString(); - auto recovery = std::move(recovery_result).value(); - auto plan = recovery->CreatePlan(); - ASSERT_TRUE(plan.ok()) << plan.status().ToString(); - EXPECT_FALSE(plan.value()->Splits().empty()); + ASSERT_OK_AND_ASSIGN(auto recovery, OrdinaryScan(pool)); + ASSERT_OK_AND_ASSIGN(auto plan, recovery->CreatePlan()); + EXPECT_FALSE(plan->Splits().empty()); } EXPECT_EQ(pool->CurrentUsage(), 0); } } -TEST(OrdinaryPlanningPoolTest, RetainedSplitRemainsUsableWithCallerOwnedPool) { +TEST(ScanExceptionSafetyTest, RetainedSplitRemainsUsableWithCallerOwnedPool) { auto pool = std::make_shared(); - auto scan_result = OrdinaryScan(pool); - ASSERT_TRUE(scan_result.ok()) << scan_result.status().ToString(); - auto scan = std::move(scan_result).value(); - auto plan_result = scan->CreatePlan(); - ASSERT_TRUE(plan_result.ok()) << plan_result.status().ToString(); - auto plan = std::move(plan_result).value(); + ASSERT_OK_AND_ASSIGN(auto scan, OrdinaryScan(pool)); + ASSERT_OK_AND_ASSIGN(auto plan, scan->CreatePlan()); ASSERT_FALSE(plan->Splits().empty()); auto split = plan->Splits().front(); - auto original = Split::Serialize(split, pool); - ASSERT_TRUE(original.ok()) << original.status().ToString(); + ASSERT_OK_AND_ASSIGN(auto original, Split::Serialize(split, pool)); scan.reset(); EXPECT_EQ(plan->SnapshotId(), 1); plan.reset(); - auto retained = Split::Serialize(split, pool); - ASSERT_TRUE(retained.ok()) << retained.status().ToString(); - EXPECT_EQ(original.value(), retained.value()); + ASSERT_OK_AND_ASSIGN(auto retained, Split::Serialize(split, pool)); + EXPECT_EQ(original, retained); split.reset(); EXPECT_EQ(pool->CurrentUsage(), 0); } -TEST(OrdinaryPlanningPoolTest, RefusedArrowReallocationPreservesExistingBuffer) { +TEST(ScanExceptionSafetyTest, RefusedArrowReallocationPreservesExistingBuffer) { auto pool = std::make_shared(); auto arrow_pool = GetArrowPool(pool); uint8_t* data = nullptr; - ASSERT_TRUE(arrow_pool->Allocate(16, 64, &data).ok()); + ASSERT_OK(ToPaimonStatus(arrow_pool->Allocate(16, 64, &data))); ASSERT_NE(data, nullptr); data[0] = 42; uint8_t* original = data; pool->fail_at = pool->calls.load(); - EXPECT_TRUE(arrow_pool->Reallocate(16, 32, 64, &data).IsOutOfMemory()); + auto status = arrow_pool->Reallocate(16, 32, 64, &data); + ASSERT_NOK(ToPaimonStatus(status)); + EXPECT_TRUE(status.IsOutOfMemory()); EXPECT_TRUE(pool->rejected); EXPECT_EQ(data, original); EXPECT_EQ(data[0], 42); @@ -323,7 +311,7 @@ TEST(OrdinaryPlanningPoolTest, RefusedArrowReallocationPreservesExistingBuffer) EXPECT_EQ(pool->CurrentUsage(), 0); } -TEST(OrdinaryPlanningPoolTest, SubmissionFailureDrainsAcceptedManifestTask) { +TEST(ScanExceptionSafetyTest, SubmissionFailureDrainsAcceptedManifestTask) { for (uint32_t phase = 0; phase < 3; ++phase) { SCOPED_TRACE(phase); PlanningInput input; @@ -339,9 +327,7 @@ TEST(OrdinaryPlanningPoolTest, SubmissionFailureDrainsAcceptedManifestTask) { } auto pool = std::make_shared(); auto executor = std::make_shared(rejected_submission); - auto result = OrdinaryScan(pool, executor, input); - ASSERT_TRUE(result.ok()) << result.status().ToString(); - auto scan = std::move(result).value(); + ASSERT_OK_AND_ASSIGN(auto scan, OrdinaryScan(pool, executor, input)); auto planning = std::async(std::launch::async, [&scan] { try { auto plan = scan->CreatePlan(); @@ -368,14 +354,12 @@ TEST(OrdinaryPlanningPoolTest, SubmissionFailureDrainsAcceptedManifestTask) { } } -TEST(OrdinaryPlanningPoolTest, WorkerPoolExceptionDrainsAcceptedManifestTask) { +TEST(ScanExceptionSafetyTest, WorkerPoolExceptionDrainsAcceptedManifestTask) { auto pool = std::make_shared(); auto executor = std::make_shared(0, 2); PlanningInput input; input.file_system = std::make_shared(pool); - auto result = OrdinaryScan(pool, executor, input); - ASSERT_TRUE(result.ok()) << result.status().ToString(); - auto scan = std::move(result).value(); + ASSERT_OK_AND_ASSIGN(auto scan, OrdinaryScan(pool, executor, input)); auto planning = std::async(std::launch::async, [&scan] { try { (void)scan->CreatePlan(); From 2dac8271270567a9dd85d6a1412b8e6042b4c298 Mon Sep 17 00:00:00 2001 From: Aaron Wu Date: Tue, 3 Nov 2026 14:05:58 +0800 Subject: [PATCH 10/11] docs(planning): clarify scan example return context --- docs/source/user_guide/read.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/user_guide/read.rst b/docs/source/user_guide/read.rst index dabeb5975..d55a141c1 100644 --- a/docs/source/user_guide/read.rst +++ b/docs/source/user_guide/read.rst @@ -44,8 +44,8 @@ across the two language ecosystems. Planning with a custom memory pool ---------------------------------- -Supply an application-owned pool through ``ScanContextBuilder::WithMemoryPool`` -while using the ordinary scan API: +Supply an application-owned pool through ``ScanContextBuilder::WithMemoryPool``. +In a function returning ``Status`` or ``Result``, use the ordinary scan API: .. code-block:: cpp From 9baf85428130fe4fbdc471627852732df1db2e9c Mon Sep 17 00:00:00 2001 From: Aaron Wu Date: Tue, 3 Nov 2026 14:06:50 +0800 Subject: [PATCH 11/11] test(planning): preserve cleanup after reallocation assertion failure --- src/paimon/core/table/source/scan_exception_safety_test.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/paimon/core/table/source/scan_exception_safety_test.cpp b/src/paimon/core/table/source/scan_exception_safety_test.cpp index 71a671de5..246ab10f7 100644 --- a/src/paimon/core/table/source/scan_exception_safety_test.cpp +++ b/src/paimon/core/table/source/scan_exception_safety_test.cpp @@ -301,7 +301,7 @@ TEST(ScanExceptionSafetyTest, RefusedArrowReallocationPreservesExistingBuffer) { uint8_t* original = data; pool->fail_at = pool->calls.load(); auto status = arrow_pool->Reallocate(16, 32, 64, &data); - ASSERT_NOK(ToPaimonStatus(status)); + EXPECT_NOK(ToPaimonStatus(status)); EXPECT_TRUE(status.IsOutOfMemory()); EXPECT_TRUE(pool->rejected); EXPECT_EQ(data, original);