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

wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new 5107eda0 fix(metadata): write and detect gzip-compressed table 
metadata (#1014)
5107eda0 is described below

commit 5107eda08bdea434ede4efee66a8e5bc7732cc22
Author: YangJie <[email protected]>
AuthorDate: Wed Oct 21 12:11:00 2026 -0400

    fix(metadata): write and detect gzip-compressed table metadata (#1014)
    
    Codec::FromFileName used find_last_of with the multi-character suffix
    '.metadata.json', which matches the last single character of the set
    (the final 'n') instead of the suffix. For gzip-compressed metadata
    written by Iceberg Java as '<name>.gz.metadata.json', the resulting
    file_name_without_suffix kept the wrong tail, the '.gz' check failed,
    and the file was misclassified as uncompressed kNone. Reading such a
    file then fails to decompress.
    
    Use rfind so the whole suffix matches. The legacy '.metadata.json.gz'
    compatibility branch is unchanged. Round-trip reading of Java-written
    gzip metadata files is covered by new unit tests.
    
    NewTableMetadataFilePath names the file '<version>-<uuid>.gz.metadata.json'
    when write.metadata.compression-codec is gzip, but Write always emitted 
plain
    JSON, so the file contradicted its own name and no reader could inflate it.
    Add a GZipCompressor next to the existing GZipDecompressor and compress in
    Write when the location's codec is gzip, completing the round trip.
---
 src/iceberg/result.h                     |  2 +
 src/iceberg/table_metadata.cc            | 10 ++++-
 src/iceberg/test/gzip_decompress_test.cc | 49 ++++++++++++++++++++++
 src/iceberg/test/metadata_io_test.cc     | 47 +++++++++++++++++++++
 src/iceberg/util/gzip_internal.cc        | 70 ++++++++++++++++++++++++++++++++
 src/iceberg/util/gzip_internal.h         | 16 ++++++++
 6 files changed, 193 insertions(+), 1 deletion(-)

diff --git a/src/iceberg/result.h b/src/iceberg/result.h
index a6666e1b..7398bcd6 100644
--- a/src/iceberg/result.h
+++ b/src/iceberg/result.h
@@ -39,6 +39,7 @@ enum class ErrorKind {
   kBadRequest,
   kCommitFailed,
   kCommitStateUnknown,
+  kCompressError,
   kDecompressError,
   kForbidden,
   kInternalServerError,
@@ -106,6 +107,7 @@ DEFINE_ERROR_FUNCTION(AuthenticationFailed)
 DEFINE_ERROR_FUNCTION(BadRequest)
 DEFINE_ERROR_FUNCTION(CommitFailed)
 DEFINE_ERROR_FUNCTION(CommitStateUnknown)
+DEFINE_ERROR_FUNCTION(CompressError)
 DEFINE_ERROR_FUNCTION(DecompressError)
 DEFINE_ERROR_FUNCTION(Forbidden)
 DEFINE_ERROR_FUNCTION(InternalServerError)
diff --git a/src/iceberg/table_metadata.cc b/src/iceberg/table_metadata.cc
index 0d24782a..b67ab32f 100644
--- a/src/iceberg/table_metadata.cc
+++ b/src/iceberg/table_metadata.cc
@@ -425,7 +425,7 @@ Result<MetadataFileCodecType> 
TableMetadataUtil::Codec::FromString(
 
 Result<MetadataFileCodecType> TableMetadataUtil::Codec::FromFileName(
     std::string_view file_name) {
-  auto pos = file_name.find_last_of(kTableMetadataFileSuffix);
+  auto pos = file_name.rfind(kTableMetadataFileSuffix);
   if (pos == std::string::npos) {
     return InvalidArgument("{} is not a valid metadata file", file_name);
   }
@@ -489,8 +489,16 @@ Result<std::string> TableMetadataUtil::Write(FileIO& io, 
const TableMetadata* ba
 
 Status TableMetadataUtil::Write(FileIO& io, const std::string& location,
                                 const TableMetadata& metadata) {
+  ICEBERG_ASSIGN_OR_RAISE(auto codec_type, Codec::FromFileName(location));
   ICEBERG_ASSIGN_OR_RAISE(auto json, ToJson(metadata));
   ICEBERG_ASSIGN_OR_RAISE(auto json_string, ToJsonString(json));
+
+  if (codec_type == MetadataFileCodecType::kGzip) {
+    auto gzip_compressor = std::make_unique<GZipCompressor>();
+    ICEBERG_RETURN_UNEXPECTED(gzip_compressor->Init());
+    ICEBERG_ASSIGN_OR_RAISE(json_string, 
gzip_compressor->Compress(json_string));
+  }
+
   return io.WriteFile(location, json_string);
 }
 
diff --git a/src/iceberg/test/gzip_decompress_test.cc 
b/src/iceberg/test/gzip_decompress_test.cc
index b855e4a0..6670a745 100644
--- a/src/iceberg/test/gzip_decompress_test.cc
+++ b/src/iceberg/test/gzip_decompress_test.cc
@@ -84,4 +84,53 @@ TEST_F(GZipTest, GZipDecompressedString) {
   ASSERT_EQ(read_data.value(), test_string);
 }
 
+TEST_F(GZipTest, GZipCompressorRoundTrip) {
+  // Larger than the 32 KiB output buffer, so compression needs several passes.
+  auto test_string = GenerateRandomString(100 * 1024);
+
+  GZipCompressor compressor;
+  ASSERT_THAT(compressor.Init(), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto compressed, compressor.Compress(test_string));
+  ASSERT_GE(compressed.size(), 2u);
+  EXPECT_EQ(static_cast<unsigned char>(compressed[0]), 0x1f);
+  EXPECT_EQ(static_cast<unsigned char>(compressed[1]), 0x8b);
+
+  GZipDecompressor decompressor;
+  ASSERT_THAT(decompressor.Init(), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto decompressed, 
decompressor.Decompress(compressed));
+  EXPECT_EQ(decompressed, test_string);
+}
+
+TEST_F(GZipTest, GZipCompressorEmptyInput) {
+  GZipCompressor compressor;
+  ASSERT_THAT(compressor.Init(), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto compressed, compressor.Compress(""));
+  // An empty payload still yields a complete gzip stream (header and trailer).
+  EXPECT_FALSE(compressed.empty());
+
+  GZipDecompressor decompressor;
+  ASSERT_THAT(decompressor.Init(), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto decompressed, 
decompressor.Decompress(compressed));
+  EXPECT_TRUE(decompressed.empty());
+}
+
+TEST_F(GZipTest, GZipCompressorReused) {
+  // One compressor can compress several payloads; each call yields a complete,
+  // independent gzip stream.
+  GZipCompressor compressor;
+  ASSERT_THAT(compressor.Init(), IsOk());
+
+  for (const auto& payload : {GenerateRandomString(1024), 
GenerateRandomString(2048)}) {
+    ICEBERG_UNWRAP_OR_FAIL(auto compressed, compressor.Compress(payload));
+    ASSERT_GE(compressed.size(), 2u);
+    EXPECT_EQ(static_cast<unsigned char>(compressed[0]), 0x1f);
+    EXPECT_EQ(static_cast<unsigned char>(compressed[1]), 0x8b);
+
+    GZipDecompressor decompressor;
+    ASSERT_THAT(decompressor.Init(), IsOk());
+    ICEBERG_UNWRAP_OR_FAIL(auto decompressed, 
decompressor.Decompress(compressed));
+    EXPECT_EQ(decompressed, payload);
+  }
+}
+
 }  // namespace iceberg
diff --git a/src/iceberg/test/metadata_io_test.cc 
b/src/iceberg/test/metadata_io_test.cc
index 03d9b0e1..ea66c7f2 100644
--- a/src/iceberg/test/metadata_io_test.cc
+++ b/src/iceberg/test/metadata_io_test.cc
@@ -138,6 +138,53 @@ TEST_F(MetadataIOTest, ReadWriteCompressedMetadata) {
   EXPECT_EQ(*metadata_read, metadata);
 }
 
+TEST_F(MetadataIOTest, CodecFromFileGzMetadataJson) {
+  // Java writers emit gzip-compressed metadata as "<name>.gz.metadata.json";
+  // the codec must be detected from the ".gz" before the metadata suffix.
+  auto result = 
TableMetadataUtil::Codec::FromFileName("00001-abc.gz.metadata.json");
+  ASSERT_TRUE(result.has_value());
+  EXPECT_EQ(*result, MetadataFileCodecType::kGzip);
+}
+
+TEST_F(MetadataIOTest, CodecFromFilePlainAndLegacyGz) {
+  auto plain = 
TableMetadataUtil::Codec::FromFileName("00001-abc.metadata.json");
+  ASSERT_TRUE(plain.has_value());
+  EXPECT_EQ(*plain, MetadataFileCodecType::kNone);
+
+  auto legacy_gz = 
TableMetadataUtil::Codec::FromFileName("00001-abc.metadata.json.gz");
+  ASSERT_TRUE(legacy_gz.has_value());
+  EXPECT_EQ(*legacy_gz, MetadataFileCodecType::kGzip);
+}
+
+TEST_F(MetadataIOTest, CodecFromFileInvalidName) {
+  // Matches Iceberg Java fromFileName: a name without the '.metadata.json'
+  // substring is rejected as not a valid metadata file.
+  EXPECT_THAT(TableMetadataUtil::Codec::FromFileName("foo.txt"),
+              IsError(ErrorKind::kInvalidArgument));
+}
+
+TEST_F(MetadataIOTest, RoundTripGzipCompressedMetadata) {
+  // With metadata compression enabled the writer names the file
+  // "<version>-<uuid>.gz.metadata.json", the same layout Iceberg Java uses.
+  // The payload must really be gzip, and reading it back must decompress it.
+  TableMetadata metadata = PrepareMetadata();
+  metadata.properties.Set(TableProperties::kMetadataCompression, 
std::string("gzip"));
+
+  ICEBERG_UNWRAP_OR_FAIL(auto written_location,
+                         TableMetadataUtil::Write(*io_, nullptr, "", 
metadata));
+  EXPECT_THAT(written_location, testing::HasSubstr(".gz.metadata.json"));
+
+  // The bytes on disk carry the gzip magic, so another engine can read them.
+  ICEBERG_UNWRAP_OR_FAIL(auto raw, io_->ReadFile(written_location, 
std::nullopt));
+  ASSERT_GE(raw.size(), 2u);
+  EXPECT_EQ(static_cast<unsigned char>(raw[0]), 0x1f);
+  EXPECT_EQ(static_cast<unsigned char>(raw[1]), 0x8b);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto metadata_read,
+                         TableMetadataUtil::Read(*io_, written_location));
+  EXPECT_EQ(*metadata_read, metadata);
+}
+
 TEST_F(MetadataIOTest, WriteMetadataWithBase) {
   TableMetadata base = PrepareMetadata();
 
diff --git a/src/iceberg/util/gzip_internal.cc 
b/src/iceberg/util/gzip_internal.cc
index 63e5acb2..f5500333 100644
--- a/src/iceberg/util/gzip_internal.cc
+++ b/src/iceberg/util/gzip_internal.cc
@@ -23,6 +23,7 @@
 #include <zlib.h>
 
 #include <cstring>
+#include <limits>
 #include <memory>
 #include <string>
 #include <vector>
@@ -86,6 +87,65 @@ class ZlibImpl {
   z_stream stream_;
 };
 
+class ZlibDeflateImpl {
+ public:
+  ZlibDeflateImpl() { memset(&stream_, 0, sizeof(stream_)); }
+
+  ~ZlibDeflateImpl() {
+    if (initialized_) {
+      deflateEnd(&stream_);
+    }
+  }
+
+  Status Init() {
+    // Maximum window size, plus the offset that selects a gzip wrapper over
+    // the raw zlib one, so GZipDecompressor's auto-detection accepts it.
+    constexpr int kWindowBits = 15;
+    constexpr int kGzipWrapper = 16;
+    int ret = deflateInit2(&stream_, Z_DEFAULT_COMPRESSION, Z_DEFLATED,
+                           kWindowBits | kGzipWrapper, 8, Z_DEFAULT_STRATEGY);
+    if (ret != Z_OK) {
+      return CompressError("deflateInit2 failed, result:{}", ret);
+    }
+    initialized_ = true;
+    return {};
+  }
+
+  Result<std::string> Compress(const std::string& data) {
+    if (data.size() > std::numeric_limits<uInt>::max()) {
+      return CompressError("Cannot compress {} bytes in a single gzip pass", 
data.size());
+    }
+    if (!initialized_) {
+      ICEBERG_RETURN_UNEXPECTED(Init());
+    }
+    stream_.avail_in = static_cast<uInt>(data.size());
+    stream_.next_in = reinterpret_cast<Bytef*>(const_cast<char*>(data.data()));
+
+    std::vector<char> out_buffer(32 * 1024);
+    std::string result;
+    int ret = 0;
+    do {
+      stream_.avail_out = static_cast<uInt>(out_buffer.size());
+      stream_.next_out = reinterpret_cast<Bytef*>(out_buffer.data());
+      ret = deflate(&stream_, Z_FINISH);
+      if (ret != Z_OK && ret != Z_STREAM_END) {
+        return CompressError("deflate failed, result:{}", ret);
+      }
+      result.append(out_buffer.data(), out_buffer.size() - stream_.avail_out);
+    } while (ret != Z_STREAM_END);
+    // Rewind the stream so the next Compress() starts a fresh gzip member.
+    ret = deflateReset(&stream_);
+    if (ret != Z_OK) {
+      return CompressError("deflateReset failed, result:{}", ret);
+    }
+    return result;
+  }
+
+ private:
+  bool initialized_ = false;
+  z_stream stream_;
+};
+
 GZipDecompressor::GZipDecompressor() : 
zlib_impl_(std::make_unique<ZlibImpl>()) {}
 
 GZipDecompressor::~GZipDecompressor() = default;
@@ -96,4 +156,14 @@ Result<std::string> GZipDecompressor::Decompress(const 
std::string& compressed_d
   return zlib_impl_->Decompress(compressed_data);
 }
 
+GZipCompressor::GZipCompressor() : 
zlib_impl_(std::make_unique<ZlibDeflateImpl>()) {}
+
+GZipCompressor::~GZipCompressor() = default;
+
+Status GZipCompressor::Init() { return zlib_impl_->Init(); }
+
+Result<std::string> GZipCompressor::Compress(const std::string& data) {
+  return zlib_impl_->Compress(data);
+}
+
 }  // namespace iceberg
diff --git a/src/iceberg/util/gzip_internal.h b/src/iceberg/util/gzip_internal.h
index 61cdc00b..b2cf0773 100644
--- a/src/iceberg/util/gzip_internal.h
+++ b/src/iceberg/util/gzip_internal.h
@@ -27,6 +27,7 @@
 namespace iceberg {
 
 class ZlibImpl;
+class ZlibDeflateImpl;
 
 class GZipDecompressor {
  public:
@@ -42,4 +43,19 @@ class GZipDecompressor {
   std::unique_ptr<ZlibImpl> zlib_impl_;
 };
 
+/// \brief Compresses data into the gzip format read by GZipDecompressor.
+class GZipCompressor {
+ public:
+  GZipCompressor();
+
+  ~GZipCompressor();
+
+  Status Init();
+
+  Result<std::string> Compress(const std::string& data);
+
+ private:
+  std::unique_ptr<ZlibDeflateImpl> zlib_impl_;
+};
+
 }  // namespace iceberg

Reply via email to