From f99bda12bbdb2793b6c217d6e896967614d2b9fe Mon Sep 17 00:00:00 2001 From: SteNicholas Date: Tue, 27 Jan 2026 17:19:36 +0800 Subject: [PATCH] feat: introduce scan.tag-name option to specify scanning from tag for reading given tag --- include/paimon/defs.h | 2 + src/paimon/CMakeLists.txt | 6 +- src/paimon/common/defs.cpp | 1 + src/paimon/core/core_options.cpp | 14 +- src/paimon/core/core_options.h | 2 + src/paimon/core/core_options_test.cpp | 4 + .../core/table/source/abstract_table_scan.h | 11 +- .../static_from_tag_starting_scanner.h | 48 ++++ src/paimon/core/tag/tag.cpp | 113 ++++++++ src/paimon/core/tag/tag.h | 86 ++++++ src/paimon/core/tag/tag_test.cpp | 244 ++++++++++++++++++ src/paimon/core/utils/snapshot_manager.cpp | 8 + src/paimon/core/utils/snapshot_manager.h | 2 + src/paimon/core/utils/tag_manager.cpp | 61 +++++ src/paimon/core/utils/tag_manager.h | 49 ++++ src/paimon/core/utils/tag_manager_test.cpp | 44 ++++ test/inte/scan_inte_test.cpp | 27 +- .../append_table_with_tag/README | 2 + ...est-f8b15cfc-437a-4d21-a6a0-e45b639ae7ed-0 | Bin 0 -> 2666 bytes ...ist-616d1847-a02c-495f-9cca-2c8b7def0fec-0 | Bin 0 -> 250 bytes ...ist-616d1847-a02c-495f-9cca-2c8b7def0fec-1 | Bin 0 -> 1184 bytes .../append_table_with_tag/schema/schema-0 | 31 +++ .../append_table_with_tag/tag/tag-1 | 18 ++ .../pk_table_scan_and_read_dv/tag/tag-1 | 18 ++ 24 files changed, 786 insertions(+), 5 deletions(-) create mode 100644 src/paimon/core/table/source/snapshot/static_from_tag_starting_scanner.h create mode 100644 src/paimon/core/tag/tag.cpp create mode 100644 src/paimon/core/tag/tag.h create mode 100644 src/paimon/core/tag/tag_test.cpp create mode 100644 src/paimon/core/utils/tag_manager.cpp create mode 100644 src/paimon/core/utils/tag_manager.h create mode 100644 src/paimon/core/utils/tag_manager_test.cpp create mode 100644 test/test_data/orc/append_table_with_tag.db/append_table_with_tag/README create mode 100644 test/test_data/orc/append_table_with_tag.db/append_table_with_tag/manifest/manifest-f8b15cfc-437a-4d21-a6a0-e45b639ae7ed-0 create mode 100644 test/test_data/orc/append_table_with_tag.db/append_table_with_tag/manifest/manifest-list-616d1847-a02c-495f-9cca-2c8b7def0fec-0 create mode 100644 test/test_data/orc/append_table_with_tag.db/append_table_with_tag/manifest/manifest-list-616d1847-a02c-495f-9cca-2c8b7def0fec-1 create mode 100644 test/test_data/orc/append_table_with_tag.db/append_table_with_tag/schema/schema-0 create mode 100644 test/test_data/orc/append_table_with_tag.db/append_table_with_tag/tag/tag-1 create mode 100644 test/test_data/orc/pk_table_scan_and_read_dv.db/pk_table_scan_and_read_dv/tag/tag-1 diff --git a/include/paimon/defs.h b/include/paimon/defs.h index 110b2884..d9bda855 100644 --- a/include/paimon/defs.h +++ b/include/paimon/defs.h @@ -287,6 +287,8 @@ struct PAIMON_EXPORT Options { /// "global-index.external-path" - Global index root directory, if not set, the global index /// files will be stored under the index directory. static const char GLOBAL_INDEX_EXTERNAL_PATH[]; + /// "scan.tag-name" - Optional tag name used in case of "from-snapshot" scan mode. + static const char SCAN_TAG_NAME[]; }; static constexpr int64_t BATCH_WRITE_COMMIT_IDENTIFIER = std::numeric_limits::max(); diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt index 33c45ba1..964bc668 100644 --- a/src/paimon/CMakeLists.txt +++ b/src/paimon/CMakeLists.txt @@ -265,6 +265,7 @@ set(PAIMON_CORE_SRCS core/table/source/table_read.cpp core/table/source/table_scan.cpp core/table/source/data_evolution_batch_scan.cpp + core/tag/tag.cpp core/utils/field_mapping.cpp core/utils/fields_comparator.cpp core/utils/file_store_path_factory.cpp @@ -273,7 +274,8 @@ set(PAIMON_CORE_SRCS core/utils/partition_path_utils.cpp core/utils/primary_key_table_utils.cpp core/utils/snapshot_manager.cpp - core/utils/special_field_ids.cpp) + core/utils/special_field_ids.cpp + core/utils/tag_manager.cpp) add_paimon_lib(paimon SOURCES @@ -564,6 +566,7 @@ if(PAIMON_BUILD_TESTS) core/table/source/split_generator_test.cpp core/table/source/startup_mode_test.cpp core/table/source/table_scan_test.cpp + core/tag/tag_test.cpp core/utils/branch_manager_test.cpp core/utils/field_mapping_test.cpp core/utils/fields_comparator_test.cpp @@ -573,6 +576,7 @@ if(PAIMON_BUILD_TESTS) core/utils/offset_row_test.cpp core/utils/partition_path_utils_test.cpp core/utils/snapshot_manager_test.cpp + core/utils/tag_manager_test.cpp core/utils/primary_key_table_utils_test.cpp core/utils/index_file_path_factories_test.cpp STATIC_LINK_LIBS diff --git a/src/paimon/common/defs.cpp b/src/paimon/common/defs.cpp index 12143c24..35bbac8e 100644 --- a/src/paimon/common/defs.cpp +++ b/src/paimon/common/defs.cpp @@ -81,4 +81,5 @@ const char Options::PARTITION_GENERATE_LEGACY_NAME[] = "partition.legacy-name"; const char Options::BLOB_AS_DESCRIPTOR[] = "blob-as-descriptor"; const char Options::GLOBAL_INDEX_ENABLED[] = "global-index.enabled"; const char Options::GLOBAL_INDEX_EXTERNAL_PATH[] = "global-index.external-path"; +const char Options::SCAN_TAG_NAME[] = "scan.tag-name"; } // namespace paimon diff --git a/src/paimon/core/core_options.cpp b/src/paimon/core/core_options.cpp index ca1c2d99..61622799 100644 --- a/src/paimon/core/core_options.cpp +++ b/src/paimon/core/core_options.cpp @@ -304,6 +304,8 @@ struct CoreOptions::Impl { bool legacy_partition_name_enabled = true; bool global_index_enabled = true; std::optional global_index_external_path; + + std::optional scan_tag_name; }; // Parse configurations from a map and return a populated CoreOptions object @@ -476,6 +478,12 @@ Result CoreOptions::FromMap( if (!global_index_external_path.empty()) { impl->global_index_external_path = global_index_external_path; } + // Parse scan.tag-name + std::string scan_tag_name; + PAIMON_RETURN_NOT_OK(parser.ParseString(Options::SCAN_TAG_NAME, &scan_tag_name)); + if (!scan_tag_name.empty()) { + impl->scan_tag_name = scan_tag_name; + } return options; } @@ -561,7 +569,7 @@ const std::string& CoreOptions::GetManifestCompression() const { StartupMode CoreOptions::GetStartupMode() const { if (impl_->startup_mode == StartupMode::Default()) { - if (GetScanSnapshotId() != std::nullopt) { + if (GetScanSnapshotId() != std::nullopt || GetScanTagName() != std::nullopt) { return StartupMode::FromSnapshot(); } return StartupMode::LatestFull(); @@ -772,4 +780,8 @@ Result> CoreOptions::CreateGlobalIndexExternalPath() return std::optional(path.ToString()); } +std::optional CoreOptions::GetScanTagName() const { + return impl_->scan_tag_name; +} + } // namespace paimon diff --git a/src/paimon/core/core_options.h b/src/paimon/core/core_options.h index f1f31a1b..ef8bca2d 100644 --- a/src/paimon/core/core_options.h +++ b/src/paimon/core/core_options.h @@ -118,6 +118,8 @@ class PAIMON_EXPORT CoreOptions { bool GlobalIndexEnabled() const; Result> CreateGlobalIndexExternalPath() const; + std::optional GetScanTagName() const; + const std::map& ToMap() const; private: diff --git a/src/paimon/core/core_options_test.cpp b/src/paimon/core/core_options_test.cpp index ee016723..c252349e 100644 --- a/src/paimon/core/core_options_test.cpp +++ b/src/paimon/core/core_options_test.cpp @@ -88,6 +88,7 @@ TEST(CoreOptionsTest, TestDefaultValue) { ASSERT_TRUE(core_options.LegacyPartitionNameEnabled()); ASSERT_TRUE(core_options.GlobalIndexEnabled()); ASSERT_FALSE(core_options.GetGlobalIndexExternalPath()); + ASSERT_EQ(std::nullopt, core_options.GetScanTagName()); } TEST(CoreOptionsTest, TestFromMap) { @@ -146,6 +147,7 @@ TEST(CoreOptionsTest, TestFromMap) { {Options::PARTITION_GENERATE_LEGACY_NAME, "false"}, {Options::GLOBAL_INDEX_ENABLED, "false"}, {Options::GLOBAL_INDEX_EXTERNAL_PATH, "FILE:///tmp/global_index/"}, + {Options::SCAN_TAG_NAME, "test-tag"}, }; ASSERT_OK_AND_ASSIGN(CoreOptions core_options, CoreOptions::FromMap(options)); @@ -216,6 +218,8 @@ TEST(CoreOptionsTest, TestFromMap) { ASSERT_FALSE(core_options.GlobalIndexEnabled()); ASSERT_TRUE(core_options.GetGlobalIndexExternalPath()); ASSERT_EQ(core_options.GetGlobalIndexExternalPath().value(), "FILE:///tmp/global_index/"); + ASSERT_EQ("test-tag", core_options.GetScanTagName().value()); + ASSERT_EQ(StartupMode::FromSnapshot(), core_options.GetStartupMode()); } TEST(CoreOptionsTest, TestInvalidCase) { diff --git a/src/paimon/core/table/source/abstract_table_scan.h b/src/paimon/core/table/source/abstract_table_scan.h index d6968a79..35eed942 100644 --- a/src/paimon/core/table/source/abstract_table_scan.h +++ b/src/paimon/core/table/source/abstract_table_scan.h @@ -25,6 +25,7 @@ #include "paimon/core/table/source/snapshot/full_starting_scanner.h" #include "paimon/core/table/source/snapshot/snapshot_reader.h" #include "paimon/core/table/source/snapshot/static_from_snapshot_starting_scanner.h" +#include "paimon/core/table/source/snapshot/static_from_tag_starting_scanner.h" #include "paimon/table/source/startup_mode.h" #include "paimon/table/source/table_scan.h" namespace paimon { @@ -51,6 +52,7 @@ class AbstractTableScan : public TableScan { return std::shared_ptr(new FullStartingScanner(snapshot_manager)); } } else if (startup_mode == StartupMode::FromSnapshot()) { + const std::optional scan_tag_name = core_options_.GetScanTagName(); if (specified_snapshot_id != std::nullopt) { return is_streaming ? std::shared_ptr( @@ -58,9 +60,16 @@ class AbstractTableScan : public TableScan { snapshot_manager, specified_snapshot_id.value())) : std::shared_ptr(new StaticFromSnapshotStartingScanner( snapshot_manager, specified_snapshot_id.value())); + } else if (scan_tag_name != std::nullopt) { + if (is_streaming) { + return Status::Invalid("Cannot scan from tag in streaming mode"); + } + return std::make_shared(snapshot_manager, + scan_tag_name.value()); } else { return Status::Invalid( - "scan.snapshot-id must be set when startup mode is FROM_SNAPSHOT"); + "scan.snapshot-id or scan.tag-name must be set when startup mode is " + "FROM_SNAPSHOT"); } } else if (startup_mode == StartupMode::FromSnapshotFull()) { if (specified_snapshot_id != std::nullopt) { diff --git a/src/paimon/core/table/source/snapshot/static_from_tag_starting_scanner.h b/src/paimon/core/table/source/snapshot/static_from_tag_starting_scanner.h new file mode 100644 index 00000000..86c3bbec --- /dev/null +++ b/src/paimon/core/table/source/snapshot/static_from_tag_starting_scanner.h @@ -0,0 +1,48 @@ +/* + * Copyright 2026-present Alibaba Inc. + * + * Licensed 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 "paimon/core/table/source/snapshot/starting_scanner.h" +#include "paimon/core/utils/tag_manager.h" + +namespace paimon { +/// `StartingScanner` for the `CoreOptions::GetScanTagName()` of a batch read. +class StaticFromTagStartingScanner : public StartingScanner { + public: + StaticFromTagStartingScanner(const std::shared_ptr& snapshot_manager, + const std::string& tag_name) + : StartingScanner(snapshot_manager) { + tag_name_ = tag_name; + } + + Result> Scan( + const std::shared_ptr& snapshot_reader) override { + const TagManager tag_manager(snapshot_manager_->Fs(), snapshot_manager_->RootPath()); + PAIMON_ASSIGN_OR_RAISE(const Tag tag, tag_manager.GetOrThrow(tag_name_)); + PAIMON_ASSIGN_OR_RAISE(const Snapshot snapshot, tag.TrimToSnapshot()); + PAIMON_ASSIGN_OR_RAISE( + std::shared_ptr plan, + snapshot_reader->WithMode(ScanMode::ALL)->WithSnapshot(snapshot)->Read()); + return std::make_shared(plan); + } + + private: + std::string tag_name_; +}; +} // namespace paimon diff --git a/src/paimon/core/tag/tag.cpp b/src/paimon/core/tag/tag.cpp new file mode 100644 index 00000000..de2d6489 --- /dev/null +++ b/src/paimon/core/tag/tag.cpp @@ -0,0 +1,113 @@ +/* + * Copyright 2026-present Alibaba Inc. + * + * Licensed 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/core/tag/tag.h" + +#include + +#include "paimon/common/utils/rapidjson_util.h" +#include "paimon/fs/file_system.h" +#include "paimon/result.h" +#include "paimon/status.h" +#include "rapidjson/document.h" +#include "rapidjson/rapidjson.h" + +namespace paimon { + +Tag::Tag(const std::optional& version, const int64_t id, const int64_t schema_id, + const std::string& base_manifest_list, + const std::optional& base_manifest_list_size, + const std::string& delta_manifest_list, + const std::optional& delta_manifest_list_size, + const std::optional& changelog_manifest_list, + const std::optional& changelog_manifest_list_size, + const std::optional& index_manifest, const std::string& commit_user, + const int64_t commit_identifier, const CommitKind commit_kind, const int64_t time_millis, + const std::optional>& log_offsets, + const std::optional& total_record_count, + const std::optional& delta_record_count, + const std::optional& changelog_record_count, + const std::optional& watermark, const std::optional& statistics, + const std::optional>& properties, + const std::optional& next_row_id, + const std::optional>& tag_create_time, + const std::optional& tag_time_retained) + : Snapshot(version, id, schema_id, base_manifest_list, base_manifest_list_size, + delta_manifest_list, delta_manifest_list_size, changelog_manifest_list, + changelog_manifest_list_size, index_manifest, commit_user, commit_identifier, + commit_kind, time_millis, log_offsets, total_record_count, delta_record_count, + changelog_record_count, watermark, statistics, properties, next_row_id), + tag_create_time_(tag_create_time), + tag_time_retained_(tag_time_retained) {} + +bool Tag::operator==(const Tag& other) const { + if (this == &other) { + return true; + } + return Snapshot::operator==(other) && tag_create_time_ == other.tag_create_time_ && + tag_time_retained_ == other.tag_time_retained_; +} + +bool Tag::TEST_Equal(const Tag& other) const { + if (this == &other) { + return true; + } + + return Snapshot::TEST_Equal(other) && tag_create_time_ == other.tag_create_time_ && + tag_time_retained_ == other.tag_time_retained_; +} + +Result Tag::TrimToSnapshot() const { + return Snapshot(Version(), Id(), SchemaId(), BaseManifestList(), BaseManifestListSize(), + DeltaManifestList(), DeltaManifestListSize(), ChangelogManifestList(), + ChangelogManifestListSize(), IndexManifest(), CommitUser(), CommitIdentifier(), + GetCommitKind(), TimeMillis(), LogOffsets(), TotalRecordCount(), + DeltaRecordCount(), ChangelogRecordCount(), Watermark(), Statistics(), + Properties(), NextRowId()); +} + +rapidjson::Value Tag::ToJson(rapidjson::Document::AllocatorType* allocator) const noexcept(false) { + rapidjson::Value obj(rapidjson::kObjectType); + obj = Snapshot::ToJson(allocator); + if (tag_create_time_ != std::nullopt) { + obj.AddMember(rapidjson::StringRef(FIELD_TAG_CREATE_TIME), + RapidJsonUtil::SerializeValue(tag_create_time_.value(), allocator).Move(), + *allocator); + } + if (tag_time_retained_ != std::nullopt) { + obj.AddMember(rapidjson::StringRef(FIELD_TAG_TIME_RETAINED), + RapidJsonUtil::SerializeValue(tag_time_retained_.value(), allocator).Move(), + *allocator); + } + return obj; +} + +void Tag::FromJson(const rapidjson::Value& obj) noexcept(false) { + Snapshot::FromJson(obj); + tag_create_time_ = RapidJsonUtil::DeserializeKeyValue>>( + obj, FIELD_TAG_CREATE_TIME); + tag_time_retained_ = + RapidJsonUtil::DeserializeKeyValue>(obj, FIELD_TAG_TIME_RETAINED); +} + +Result Tag::FromPath(const std::shared_ptr& fs, const std::string& path) { + std::string json_str; + PAIMON_RETURN_NOT_OK(fs->ReadFile(path, &json_str)); + Tag tag; + PAIMON_RETURN_NOT_OK(RapidJsonUtil::FromJsonString(json_str, &tag)); + return tag; +} +} // namespace paimon diff --git a/src/paimon/core/tag/tag.h b/src/paimon/core/tag/tag.h new file mode 100644 index 00000000..a90639e5 --- /dev/null +++ b/src/paimon/core/tag/tag.h @@ -0,0 +1,86 @@ +/* + * Copyright 2026-present Alibaba Inc. + * + * Licensed 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 +#include + +#include "paimon/common/utils/jsonizable.h" +#include "paimon/core/snapshot.h" +#include "paimon/result.h" +#include "rapidjson/document.h" +#include "rapidjson/rapidjson.h" + +namespace paimon { +class FileSystem; + +/// Snapshot with tagCreateTime and tagTimeRetained. +class Tag : public Snapshot { + public: + static constexpr char FIELD_TAG_CREATE_TIME[] = "tagCreateTime"; + static constexpr char FIELD_TAG_TIME_RETAINED[] = "tagTimeRetained"; + + JSONIZABLE_FRIEND_AND_DEFAULT_CTOR(Tag); + + Tag(const std::optional& version, int64_t id, int64_t schema_id, + const std::string& base_manifest_list, + const std::optional& base_manifest_list_size, + const std::string& delta_manifest_list, + const std::optional& delta_manifest_list_size, + const std::optional& changelog_manifest_list, + const std::optional& changelog_manifest_list_size, + const std::optional& index_manifest, const std::string& commit_user, + int64_t commit_identifier, CommitKind commit_kind, int64_t time_millis, + const std::optional>& log_offsets, + const std::optional& total_record_count, + const std::optional& delta_record_count, + const std::optional& changelog_record_count, + const std::optional& watermark, const std::optional& statistics, + const std::optional>& properties, + const std::optional& next_row_id, + const std::optional>& tag_create_time, + const std::optional& tag_time_retained); + + bool operator==(const Tag& other) const; + bool TEST_Equal(const Tag& other) const; + + std::optional> TagCreateTime() const { + return tag_create_time_; + } + + std::optional TagTimeRetained() const { + return tag_time_retained_; + } + + Result TrimToSnapshot() const; + + rapidjson::Value ToJson(rapidjson::Document::AllocatorType* allocator) const + noexcept(false) override; + + void FromJson(const rapidjson::Value& obj) noexcept(false) override; + + static Result FromPath(const std::shared_ptr& fs, const std::string& path); + + private: + std::optional> tag_create_time_; + std::optional tag_time_retained_; +}; +} // namespace paimon diff --git a/src/paimon/core/tag/tag_test.cpp b/src/paimon/core/tag/tag_test.cpp new file mode 100644 index 00000000..8faa6d2d --- /dev/null +++ b/src/paimon/core/tag/tag_test.cpp @@ -0,0 +1,244 @@ +/* + * Copyright 2026-present Alibaba Inc. + * + * Licensed 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/core/tag/tag.h" + +#include "gtest/gtest.h" +#include "paimon/common/utils/string_utils.h" +#include "paimon/fs/local/local_file_system.h" +#include "paimon/result.h" +#include "paimon/status.h" +#include "paimon/testing/utils/testharness.h" + +namespace paimon::test { + +class TagTest : public testing::Test { + public: + static std::string ReplaceAll(const std::string& str, const bool serialized) { + std::string replaced_str = StringUtils::Replace(str, " ", ""); + replaced_str = StringUtils::Replace(replaced_str, "\t", ""); + replaced_str = StringUtils::Replace(replaced_str, "\n", ""); + if (serialized) { + replaced_str = StringUtils::Replace(replaced_str, ".0", ".000000000"); + } + return replaced_str; + } +}; + +TEST_F(TagTest, TestSimple) { + const std::map log_offset = {{25, 30}}; + const std::map properties = {{"key1", "value1"}, {"key2", "value2"}}; + const auto tag_create_time = std::vector({2026, 1, 2, 3, 4, 5, 6}); + const Tag tag( + /*version=*/5, /*id=*/10, /*schema_id=*/15, /*base_manifest_list=*/"base_manifest_list", 10, + /*delta_manifest_list=*/"delta_manifest_list", 20, + /*changelog_manifest_list=*/"changelog_manifest_list", 30, + /*index_manifest=*/"index_manifest", + /*commit_user=*/"commit_user_01", /*commit_identifier=*/20, + /*commit_kind=*/Snapshot::CommitKind::Compact(), /*time_millis=*/1234, log_offset, + /*total_record_count=*/35, + /*delta_record_count=*/40, /*changelog_record_count=*/45, /*watermark=*/50, + /*statistics=*/"statistic_test", properties, /*next_row_id=*/0, + /*tag_create_time=*/tag_create_time, /*tag_time_retained=*/5.0); + ASSERT_EQ(5, tag.Version()); + ASSERT_EQ(10, tag.Id()); + ASSERT_EQ(15, tag.SchemaId()); + ASSERT_EQ("base_manifest_list", tag.BaseManifestList()); + ASSERT_EQ(10, tag.BaseManifestListSize().value()); + ASSERT_EQ("delta_manifest_list", tag.DeltaManifestList()); + ASSERT_EQ(20, tag.DeltaManifestListSize().value()); + ASSERT_EQ("changelog_manifest_list", tag.ChangelogManifestList().value()); + ASSERT_EQ(30, tag.ChangelogManifestListSize().value()); + ASSERT_EQ("index_manifest", tag.IndexManifest().value()); + ASSERT_EQ("commit_user_01", tag.CommitUser()); + ASSERT_EQ(20, tag.CommitIdentifier()); + ASSERT_EQ(Snapshot::CommitKind::Compact(), tag.GetCommitKind()); + ASSERT_EQ(1234, tag.TimeMillis()); + ASSERT_EQ(log_offset, tag.LogOffsets().value()); + ASSERT_EQ(35, tag.TotalRecordCount().value()); + ASSERT_EQ(40, tag.DeltaRecordCount().value()); + ASSERT_EQ(45, tag.ChangelogRecordCount().value()); + ASSERT_EQ(50, tag.Watermark().value()); + ASSERT_EQ("statistic_test", tag.Statistics().value()); + ASSERT_EQ(properties, tag.Properties().value()); + ASSERT_EQ(0, tag.NextRowId().value()); + ASSERT_EQ(tag_create_time, tag.TagCreateTime().value()); + ASSERT_EQ(5.0, tag.TagTimeRetained().value()); +} + +TEST_F(TagTest, TestFromPath) { + const std::string data_path = paimon::test::GetDataDir() + + "/orc/append_table_with_tag.db/append_table_with_tag/tag/tag-1"; + const auto fs = std::make_shared(); + ASSERT_OK_AND_ASSIGN(Tag tag, Tag::FromPath(fs, data_path)); + ASSERT_EQ(3, tag.Version()); + ASSERT_EQ(1, tag.Id()); + ASSERT_EQ(0, tag.SchemaId()); + ASSERT_EQ("manifest-list-616d1847-a02c-495f-9cca-2c8b7def0fec-0", tag.BaseManifestList()); + ASSERT_EQ(std::nullopt, tag.BaseManifestListSize()); + ASSERT_EQ("manifest-list-616d1847-a02c-495f-9cca-2c8b7def0fec-1", tag.DeltaManifestList()); + ASSERT_EQ(std::nullopt, tag.DeltaManifestListSize()); + ASSERT_EQ(std::nullopt, tag.ChangelogManifestList()); + ASSERT_EQ(std::nullopt, tag.ChangelogManifestListSize()); + ASSERT_EQ(std::nullopt, tag.IndexManifest()); + ASSERT_EQ("b02e4322-9c5f-41e1-a560-c0156fdf7b9c", tag.CommitUser()); + ASSERT_EQ(9223372036854775807ll, tag.CommitIdentifier()); + ASSERT_EQ(Snapshot::CommitKind::Append(), tag.GetCommitKind()); + ASSERT_EQ(1721614343270ll, tag.TimeMillis()); + ASSERT_EQ((std::map()), tag.LogOffsets().value()); + ASSERT_EQ(5, tag.TotalRecordCount().value()); + ASSERT_EQ(5, tag.DeltaRecordCount().value()); + ASSERT_EQ(0, tag.ChangelogRecordCount().value()); + ASSERT_EQ(std::nullopt, tag.Watermark()); + ASSERT_EQ(std::nullopt, tag.Statistics()); + ASSERT_EQ(std::nullopt, tag.Properties()); + ASSERT_EQ(std::nullopt, tag.NextRowId()); + ASSERT_EQ(std::vector({2026, 2, 4, 6, 8, 10, 12}), tag.TagCreateTime()); + ASSERT_EQ(3.0, tag.TagTimeRetained()); +} + +TEST_F(TagTest, TestJsonizable) { + const std::string json_str = R"({ + "version" : 3, + "id" : 1, + "schemaId" : 0, + "baseManifestList" : "manifest-list-d96fcc30-99e8-4f45-962b-a1157c56f378-0", + "baseManifestListSize" : 20, + "deltaManifestList" : "manifest-list-d96fcc30-99e8-4f45-962b-a1157c56f378-1", + "deltaManifestListSize" : 50, + "changelogManifestList" : null, + "commitUser" : "0e4d92f7-53b0-40d6-a7c0-102bf3801e6a", + "commitIdentifier" : 9223372036854775807, + "commitKind" : "OVERWRITE", + "timeMillis" : 1711692199281, + "logOffsets" : { }, + "totalRecordCount" : 3, + "deltaRecordCount" : 3, + "changelogRecordCount" : 0, + "tagCreateTime" : [ 2026, 1, 3, 5, 7, 9, 11 ], + "tagTimeRetained" : 4.000000000 + })"; + + Tag tag; + ASSERT_OK(RapidJsonUtil::FromJsonString(json_str, &tag)); + + const Tag expected_tag( + /*version=*/3, /*id=*/1, /*schema_id=*/0, /*base_manifest_list=*/ + "manifest-list-d96fcc30-99e8-4f45-962b-a1157c56f378-0", /*base_manifest_list_size=*/20, + /*delta_manifest_list=*/"manifest-list-d96fcc30-99e8-4f45-962b-a1157c56f378-1", + /*delta_manifest_list_size=*/50, /*changelog_manifest_list=*/std::nullopt, + /*changelog_manifest_list_size=*/std::nullopt, /*index_manifest=*/std::nullopt, + /*commit_user=*/"0e4d92f7-53b0-40d6-a7c0-102bf3801e6a", + /*commit_identifier=*/9223372036854775807ll, + /*commit_kind=*/Snapshot::CommitKind::Overwrite(), /*time_millis=*/1711692199281ll, + /*log_offsets=*/std::map(), + /*total_record_count=*/3, /*delta_record_count=*/3, /*changelog_record_count=*/0, + /*watermark=*/std::nullopt, /*statistics=*/std::nullopt, /*properties=*/std::nullopt, + /*next_row_id=*/std::nullopt, + /*tag_create_time=*/std::vector({2026, 1, 3, 5, 7, 9, 11}), + /*tag_time_retained=*/4.0); + ASSERT_EQ(expected_tag, tag); + + ASSERT_OK_AND_ASSIGN(std::string new_json_str, tag.ToJsonString()); + ASSERT_EQ(ReplaceAll(json_str, false), ReplaceAll(new_json_str, true)); +} + +TEST_F(TagTest, TestSerializeAndDeserialize) { + const auto se_and_de = [&](const std::string& data_path) { + auto fs = std::make_shared(); + std::string json_str; + ASSERT_OK(fs->ReadFile(data_path, &json_str)); + ASSERT_OK_AND_ASSIGN(Tag tag, Tag::FromPath(fs, data_path)); + ASSERT_OK_AND_ASSIGN(std::string se_json_str, tag.ToJsonString()); + ASSERT_EQ(ReplaceAll(json_str, false), ReplaceAll(se_json_str, true)); + }; + auto se_and_de_from_str = [&](const std::string& json_str) { + Tag tag; + ASSERT_OK(RapidJsonUtil::FromJsonString(json_str, &tag)); + ASSERT_OK_AND_ASSIGN(std::string se_json_str, tag.ToJsonString()); + ASSERT_EQ(ReplaceAll(json_str, false), ReplaceAll(se_json_str, true)); + }; + { + const std::string data_path = + paimon::test::GetDataDir() + + "/orc/pk_table_scan_and_read_dv.db/pk_table_scan_and_read_dv/tag/tag-1"; + se_and_de(data_path); + } + { + // with tagCreateTime + const std::string json_str = R"({ + "version" : 3, + "id" : 10, + "schemaId" : 2, + "baseManifestList" : "base-manifest-list-1", + "baseManifestListSize" : 100, + "deltaManifestList" : "delta-manifest-list-2", + "deltaManifestListSize" : 200, + "changelogManifestList" : null, + "commitUser" : "commit-usr-3", + "commitIdentifier" : 12, + "commitKind" : "APPEND", + "timeMillis" : 1749724197266, + "logOffsets" : { + "0" : 1, + "1" : 3 + }, + "totalRecordCount" : 1024, + "deltaRecordCount" : 4096, + "watermark" : 1749724196266, + "statistics" : "statistics-4", + "properties" : { + "key0" : "value0", + "key1" : "value1" + }, + "tagCreateTime": [ 2026, 2, 4, 6, 8, 10, 12 ] + })"; + se_and_de_from_str(json_str); + } + { + // with tagTimeRetained + const std::string json_str = R"({ + "version" : 3, + "id" : 10, + "schemaId" : 2, + "baseManifestList" : "base-manifest-list-1", + "baseManifestListSize" : 100, + "deltaManifestList" : "delta-manifest-list-2", + "deltaManifestListSize" : 200, + "changelogManifestList" : null, + "commitUser" : "commit-usr-3", + "commitIdentifier" : 12, + "commitKind" : "APPEND", + "timeMillis" : 1749724197266, + "logOffsets" : { + "0" : 1, + "1" : 3 + }, + "totalRecordCount" : 1024, + "deltaRecordCount" : 4096, + "watermark" : 1749724196266, + "statistics" : "statistics-4", + "properties" : { + "key0" : "value0", + "key1" : "value1" + }, + "tagTimeRetained" : 2.000000000 + })"; + se_and_de_from_str(json_str); + } +} + +} // namespace paimon::test diff --git a/src/paimon/core/utils/snapshot_manager.cpp b/src/paimon/core/utils/snapshot_manager.cpp index fc5e2cce..0990d958 100644 --- a/src/paimon/core/utils/snapshot_manager.cpp +++ b/src/paimon/core/utils/snapshot_manager.cpp @@ -42,6 +42,14 @@ SnapshotManager::SnapshotManager(const std::shared_ptr& fs, SnapshotManager::~SnapshotManager() = default; +const std::shared_ptr& SnapshotManager::Fs() const { + return fs_; +} + +const std::string& SnapshotManager::RootPath() const { + return root_path_; +} + const std::string& SnapshotManager::Branch() const { return branch_; } diff --git a/src/paimon/core/utils/snapshot_manager.h b/src/paimon/core/utils/snapshot_manager.h index 3b733082..688f1518 100644 --- a/src/paimon/core/utils/snapshot_manager.h +++ b/src/paimon/core/utils/snapshot_manager.h @@ -45,6 +45,8 @@ class SnapshotManager { const std::string& branch); ~SnapshotManager(); + const std::shared_ptr& Fs() const; + const std::string& RootPath() const; const std::string& Branch() const; Result> LatestSnapshot() const; std::string SnapshotDirectory() const; diff --git a/src/paimon/core/utils/tag_manager.cpp b/src/paimon/core/utils/tag_manager.cpp new file mode 100644 index 00000000..b57ff89a --- /dev/null +++ b/src/paimon/core/utils/tag_manager.cpp @@ -0,0 +1,61 @@ +/* + * Copyright 2026-present Alibaba Inc. + * + * Licensed 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/core/utils/tag_manager.h" + +#include + +#include +#include +#include + +#include "fmt/format.h" +#include "paimon/common/utils/path_util.h" +#include "paimon/core/tag/tag.h" +#include "paimon/core/utils/branch_manager.h" + +namespace paimon { + +TagManager::TagManager(const std::shared_ptr& fs, const std::string& root_path) + : TagManager(fs, root_path, BranchManager::DEFAULT_MAIN_BRANCH) {} + +TagManager::TagManager(const std::shared_ptr& fs, const std::string& root_path, + const std::string& branch) + : fs_(fs), root_path_(root_path), branch_(BranchManager::NormalizeBranch(branch)) {} + +Result TagManager::GetOrThrow(const std::string& tag_name) const { + PAIMON_ASSIGN_OR_RAISE(std::optional tag, Get(tag_name)); + if (tag == std::nullopt) { + return Status::NotExist(fmt::format("Tag '{}' doesn't exist.", tag_name)); + } + return tag.value(); +} + +Result> TagManager::Get(const std::string& tag_name) const { + std::string tag_path = TagPath(tag_name); + PAIMON_ASSIGN_OR_RAISE(bool is_exist, fs_->Exists(tag_path)); + if (!is_exist) { + return std::optional(); + } + PAIMON_ASSIGN_OR_RAISE(Tag tag, Tag::FromPath(fs_, tag_path)); + return std::optional(std::move(tag)); +} + +std::string TagManager::TagPath(const std::string& tag_name) const { + return PathUtil::JoinPath(BranchManager::BranchPath(root_path_, branch_), + "/tag/" + std::string(TAG_PREFIX) + tag_name); +} +} // namespace paimon diff --git a/src/paimon/core/utils/tag_manager.h b/src/paimon/core/utils/tag_manager.h new file mode 100644 index 00000000..0aad3022 --- /dev/null +++ b/src/paimon/core/utils/tag_manager.h @@ -0,0 +1,49 @@ +/* + * Copyright 2026-present Alibaba Inc. + * + * Licensed 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/core/tag/tag.h" + +namespace paimon { + +class FileSystem; + +/// Manager for `Tag`. +class TagManager { + public: + static constexpr char TAG_PREFIX[] = "tag-"; + + TagManager(const std::shared_ptr& fs, const std::string& root_path); + TagManager(const std::shared_ptr& fs, const std::string& root_path, + const std::string& branch); + + Result GetOrThrow(const std::string& tag_name) const; + + Result> Get(const std::string& tag_name) const; + + std::string TagPath(const std::string& tag_name) const; + + private: + std::shared_ptr fs_; + std::string root_path_; + std::string branch_; +}; +} // namespace paimon diff --git a/src/paimon/core/utils/tag_manager_test.cpp b/src/paimon/core/utils/tag_manager_test.cpp new file mode 100644 index 00000000..d5b223e1 --- /dev/null +++ b/src/paimon/core/utils/tag_manager_test.cpp @@ -0,0 +1,44 @@ +/* + * Copyright 2026-present Alibaba Inc. + * + * Licensed 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/core/utils/tag_manager.h" + +#include "gtest/gtest.h" +#include "paimon/core/utils/branch_manager.h" +#include "paimon/fs/local/local_file_system.h" +#include "paimon/testing/utils/testharness.h" + +namespace paimon::test { + +TEST(TagManagerTest, TestGet) { + ASSERT_OK_AND_ASSIGN(auto tag_opt, + TagManager(std::make_shared(), + paimon::test::GetDataDir() + + "/orc/append_table_with_tag.db/append_table_with_tag") + .Get("1")); + ASSERT_TRUE(tag_opt.has_value()); + const auto& tag = *tag_opt; + ASSERT_EQ(std::vector({2026, 2, 4, 6, 8, 10, 12}), tag.TagCreateTime()); + ASSERT_EQ(3.0, tag.TagTimeRetained()); +} + +TEST(TagManagerTest, TestTagPath) { + ASSERT_EQ("/root/tag/tag-data", TagManager(nullptr, "/root").TagPath("data")); + ASSERT_EQ("/root/branch/branch-data/tag/tag-data", + TagManager(nullptr, "/root", "data").TagPath("data")); +} + +} // namespace paimon::test diff --git a/test/inte/scan_inte_test.cpp b/test/inte/scan_inte_test.cpp index f91cb558..9cda9248 100644 --- a/test/inte/scan_inte_test.cpp +++ b/test/inte/scan_inte_test.cpp @@ -928,8 +928,9 @@ TEST_F(ScanInteTest, TestScanAppendWithInvalidOptions) { .WithStreamingMode(true); ASSERT_OK_AND_ASSIGN(auto scan_context, context_builder.Finish()); ASSERT_OK_AND_ASSIGN(auto table_scan, TableScan::Create(std::move(scan_context))); - ASSERT_NOK_WITH_MSG(table_scan->CreatePlan(), - "scan.snapshot-id must be set when startup mode is FROM_SNAPSHOT"); + ASSERT_NOK_WITH_MSG( + table_scan->CreatePlan(), + "scan.snapshot-id or scan.tag-name must be set when startup mode is FROM_SNAPSHOT"); } { ScanContextBuilder context_builder(table_path); @@ -2079,4 +2080,26 @@ TEST_F(ScanInteTest, TestScanAppendWithBitmapAndAlterTableWithEmptyResult) { ASSERT_TRUE(result_plan->Splits().empty()); } +TEST_F(ScanInteTest, TestScanAppendWithTag1) { + std::string table_path = + paimon::test::GetDataDir() + "orc/append_table_with_tag.db/append_table_with_tag"; + ScanContextBuilder context_builder(table_path); + context_builder.AddOption(Options::SCAN_TAG_NAME, "1"); + ASSERT_OK_AND_ASSIGN(std::unique_ptr scan_context, context_builder.Finish()); + ASSERT_OK_AND_ASSIGN(auto table_scan, TableScan::Create(std::move(scan_context))); + ASSERT_OK_AND_ASSIGN(auto result_plan, table_scan->CreatePlan()); + // check snapshot id + ASSERT_EQ(1, result_plan->SnapshotId().value()); +} + +TEST_F(ScanInteTest, TestScanInvalidTag) { + std::string table_path = + paimon::test::GetDataDir() + "orc/append_table_with_tag.db/append_table_with_tag"; + ScanContextBuilder context_builder(table_path); + context_builder.AddOption(Options::SCAN_TAG_NAME, "unknown"); + ASSERT_OK_AND_ASSIGN(auto scan_context, context_builder.Finish()); + ASSERT_OK_AND_ASSIGN(auto table_scan, TableScan::Create(std::move(scan_context))); + ASSERT_NOK_WITH_MSG(table_scan->CreatePlan(), "Tag 'unknown' doesn't exist."); +} + } // namespace paimon::test diff --git a/test/test_data/orc/append_table_with_tag.db/append_table_with_tag/README b/test/test_data/orc/append_table_with_tag.db/append_table_with_tag/README new file mode 100644 index 00000000..e9fb1128 --- /dev/null +++ b/test/test_data/orc/append_table_with_tag.db/append_table_with_tag/README @@ -0,0 +1,2 @@ +The directory of table `append_table_with_tag.append_table_with_tag` is copied from table `append_09.append_09,` +which adds `tag` directory to verify the tag scanned from append table. \ No newline at end of file diff --git a/test/test_data/orc/append_table_with_tag.db/append_table_with_tag/manifest/manifest-f8b15cfc-437a-4d21-a6a0-e45b639ae7ed-0 b/test/test_data/orc/append_table_with_tag.db/append_table_with_tag/manifest/manifest-f8b15cfc-437a-4d21-a6a0-e45b639ae7ed-0 new file mode 100644 index 0000000000000000000000000000000000000000..4ac1909b69f2fe67df63d1b3dbbf17478f636a90 GIT binary patch literal 2666 zcmb_cdpK12AO4;*w=)dJ7-QJMxHQu!Dntoym7hwPoPh&_-v_I!rPTV!x^>zd9_rKD!d--8^KWyvhEEQuD;MYvL z^cm+^LT{Ux;{LA9yV8G^etod57T(&IU)dnOq8<>wKP$`eX7<%3qgE%xe^OzL(;>to z62<`R-+I0nj0Cp?RY03jtb!^gB9#bv8lk_}oP#b*#?uw%6zx18`jxafW;Jixx2(JX zkBEp6vIwDz5CfF3$_!xy0TfuahOfGqpx2Zy8zG__!5|6f=;wj|rO!83AfZ;6UHLb- z%p&vSSUPr&Db61+x75E-(nYl?%}7Vv>Z8IPQG}_0m-`P;Xb9#%WcuB!LuYe?nW?3+ zj~O&S15-3vU4b~0JY!&h8W?y%^J$bB9V>Uc_pDFta1ba$Q3xo^KGfNII5N=GIK8v_ zBu$n;B>=QGQ(mBPHaF!H4D=J!)rzeN1WDQUKkFgQj%-dJIb##9)>u@M9#QlW_K~i~ zPq(w*mek(b^?4#?W2uGfj<5%I3$RIt%hR`uwKZ{Gv_Ti`VcfuMu)Xw zkO`vz7C3}$-?9~eBeHo}uAr!Qf#y)6(RwNVy$8)^s)-E2h0yX$&w|a@8~NqKgt-g zWiKo`mMTR6k1KEBLuT_qM~wrM`R7Y(PW5eeX}GcIQdzh6NJ?T_vDXqZa>0pPO<5#eVeZ%0 zw=wt1fgra!=V%jFxo>%1GJC<6UN^=*veUf|`i*Ep+~883(#q&qA3m-a=xnM;@G!xyyy?33MPqz~R=;W0HI;f;Qn7l{oY5q-3z^7|YB33D_0{kw zayVVx@(V9W3FI3@s_w>(;1*dCQWEzW?jnkh;r z`-=XtuIu-7&F(s(sQ!>|&g?J@ zA$0|?UUj8q#2pvH;gRXjJ$F8z(*d3~I^7HPxz*dOpO^5>w?stkmu@@uYfXIpz37z= z2O7#;T#=4&A5&ls76Zjp>4V@dyDxJi}4BuonsqU%Y{TaszbKp!Wd}gs_~p1+lmfc{S|zf)epj=%|H-5v$~m22IZ| z#(}@^Hpvh2&>I!xU!KgA)}74ES7E0M3e^&0bZvU4E?HV7#Q1>U@HtejZf5iS$kb6M z)wGznp8nA0O#z}9>%Bs|yd>hmjAFCdiRL>`XL;qu6$ODnJu&Hrys^g{-QTQl)_KElRg4@y7Vyv2m#;jErW|v%p7@ssYRB?5 z?mZf^eDz9wld#}^RJmEva?5s;p#wJ+t;B^xEx3$IwX?i7owa%7-O)`(?NPfGPw+#s zySZ`u?p!Zk^}aFhoLl1e?X_Lm4kvLNE1y@mOVj!miEWe{Bqp>OJRZADZ^I3)fsK!Rv(AE3ZHDYNNS0D7$2;%YelVRQAY)i9Zm9J-2_a8Z2X&H;D0C%b0<%UeN%4dQ6-ulCYl! z!(=ljd)T(?T$i){)(PQ#I&+fq2Adi@ZDVX(9|lJIw@g$->x?tkEGesN?ZdKIFp9nA9 sldp+>VXCYcG!?#$GTgrwxrig<=pqrK4+E0%BiM|1XdHv_^yX^*57iJo0{{R3 literal 0 HcmV?d00001 diff --git a/test/test_data/orc/append_table_with_tag.db/append_table_with_tag/manifest/manifest-list-616d1847-a02c-495f-9cca-2c8b7def0fec-0 b/test/test_data/orc/append_table_with_tag.db/append_table_with_tag/manifest/manifest-list-616d1847-a02c-495f-9cca-2c8b7def0fec-0 new file mode 100644 index 0000000000000000000000000000000000000000..c7de35987dcfefa36262d47f1431a4dd68ef1040 GIT binary patch literal 250 zcmV3bAwtcoZ7Q(HD< zjlzcT{ha)t)(MgJeEuyX!ZXdINEN+!ZjF*4OC1YySi!AlaSs5X08eqHrDIklS~mRL zz#7>W{K}nJ!z3fSqHor#c}~s-{^n3tKBT3n)=W|3rQnw*xbYhrAksB4m9WT=~HmS~`xYGRsX zW^9?5YMz>+Yak>M7r?;8AQ8%_yMWaYXq_0uI)0#aoE%p)1+HjHT+zhh5;g%g2{tU^ z3<3-i3;_&Co=^vxDhPBT2nca-Fgh`DXaq1|7e#eJ07DWdM+1jI1BXNd2S!*xZNeqa z&cNWt;1JKiKw^jh_4zUAb22c*F^F6N8=Z{`Ri8EN+Z}66T5|PP#G1Fv&*OPSmLQ&&9M@5bkdn_Mot?%}}B#c-jqgEv5td%dxhG54MMmSXGrk~Rst zDvPpSV}iK(7XvRhLz#$=Nx;fi2Aw)e#>UJOec1AZOXR;-e^XibcCp|-#rgl2%{nGK z@Atm^=c;ek-MRZD=naQ>ink`S4TCj%`ca2 zV*PvSHuI-`wy)=Vyc~V6M@fF;nO$nbaf9Qn*MpZEeS9ChWbXREyRc}woFD(;qVh9e zj+{FYGkqcZD`^pHW&L}zjNT|;xqtT8Zr}Rt{BP#1u$H;E{)}h;liF$rAr*VZMJAKy z&E{g;8xiDJl;oG^BpA8FICx{h!oU|sj;6}z7Andr>dfJq=)L8p{|l|neN)^LU#&i4 z=Me7xJ0x9o`q_2-A}8340vB9cGnd!!gUVaaAD;Uiv^V#ClV(hlm96J|6Cb*IM_5#= z_USc`7ca5(`VsicHEsXJ%rjPU88chBg|G%KqpRsL)qtJ5vuC8Fgnv0(WqSVP&4$0a g)g14b1Xv{+8U&P>7