Repository: mongodb/mongo-spark
Pull request #133: Spark 442
Author: login: rozza
Created: 2025-04-24T13:00:52Z | Merged: 2025-04-30T08:17:11Z

Simplified approach to #132

Ensures the whole collection count is used when calculating the average size of documents.


Diff:
diff --git a/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/AutoBucketPartitioner.java b/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/AutoBucketPartitioner.java
index 98efd267..cdaea0c1 100644
--- a/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/AutoBucketPartitioner.java
+++ b/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/AutoBucketPartitioner.java
@@ -33,7 +33,6 @@
 import java.util.ArrayList;
 import java.util.List;
 import org.bson.BsonDocument;
-import org.bson.BsonInt32;
 import org.bson.BsonString;
 import org.bson.conversions.Bson;
 import org.jetbrains.annotations.ApiStatus;
@@ -150,8 +149,7 @@ public List<MongoInputPartition> generatePartitions(final ReadConfig readConfig)
       return SINGLE_PARTITIONER.generatePartitions(readConfig);
     }
 
-    double avgObjSizeInBytes =
-        storageStats.get("avgObjSize", new BsonInt32(0)).asNumber().doubleValue();
+    double avgObjSizeInBytes = PartitionerHelper.averageDocumentSize(storageStats);
     double numDocumentsPerPartition = Math.floor(partitionSizeInBytes / avgObjSizeInBytes);
 
     BsonDocument usersCollectionFilter =
diff --git a/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/PaginateBySizePartitioner.java b/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/PaginateBySizePartitioner.java
index 25702a64..1fcba43b 100644
--- a/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/PaginateBySizePartitioner.java
+++ b/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/PaginateBySizePartitioner.java
@@ -27,7 +27,6 @@
 import com.mongodb.spark.sql.connector.read.MongoInputPartition;
 import java.util.List;
 import org.bson.BsonDocument;
-import org.bson.BsonInt32;
 import org.jetbrains.annotations.ApiStatus;
 
 /**
@@ -55,7 +54,7 @@ public PaginateBySizePartitioner() {}
   @Override
   public List<MongoInputPartition> generatePartitions(final ReadConfig readConfig) {
     MongoConfig partitionerOptions = readConfig.getPartitionerOptions();
-    int partitionSizeBytes = Assertions.validateConfig(
+    int partitionSizeInBytes = Assertions.validateConfig(
             partitionerOptions.getInt(PARTITION_SIZE_MB_CONFIG, PARTITION_SIZE_MB_DEFAULT),
             i -> i > 0,
             () ->
@@ -69,18 +68,17 @@ public List<MongoInputPartition> generatePartitions(final ReadConfig readConfig)
       return SINGLE_PARTITIONER.generatePartitions(readConfig);
     }
 
-    double avgObjSizeInBytes =
-        storageStats.get("avgObjSize", new BsonInt32(0)).asNumber().doubleValue();
-    if (avgObjSizeInBytes >= partitionSizeBytes) {
+    double avgObjSizeInBytes = PartitionerHelper.averageDocumentSize(storageStats);
+    if (avgObjSizeInBytes >= partitionSizeInBytes) {
       LOGGER.warn(
           "Average document size `{}` is greater than the partition size `{}`. Please increase the partition size."
               + "Returning a single partition.",
           avgObjSizeInBytes,
-          partitionSizeBytes);
+          partitionSizeInBytes);
       return SINGLE_PARTITIONER.generatePartitions(readConfig);
     }
 
-    int numDocumentsPerPartition = (int) Math.floor(partitionSizeBytes / avgObjSizeInBytes);
+    int numDocumentsPerPartition = (int) Math.floor(partitionSizeInBytes / avgObjSizeInBytes);
     BsonDocument matchQuery = PartitionerHelper.matchQuery(readConfig.getAggregationPipeline());
     long count;
     if (matchQuery.isEmpty() && storageStats.containsKey("count")) {
diff --git a/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/PartitionerHelper.java b/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/PartitionerHelper.java
index 43ce9120..12763d47 100644
--- a/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/PartitionerHelper.java
+++ b/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/PartitionerHelper.java
@@ -18,7 +18,7 @@
 package com.mongodb.spark.sql.connector.read.partitioner;
 
 import static com.mongodb.spark.sql.connector.read.partitioner.Partitioner.LOGGER;
-import static java.util.Collections.singletonList;
+import static java.util.Arrays.asList;
 
 import com.mongodb.MongoCommandException;
 import com.mongodb.client.MongoDatabase;
@@ -31,14 +31,21 @@
 import java.util.stream.Collectors;
 import org.apache.spark.sql.connector.read.Scan;
 import org.bson.BsonDocument;
+import org.bson.BsonInt32;
 import org.bson.BsonType;
 import org.bson.BsonValue;
 
 /** Partitioner helper class, contains various utility methods used by the partitioner instances. */
 public final class PartitionerHelper {
 
-  private static final List<BsonDocument> COLL_STATS_AGGREGATION_PIPELINE =
-      singletonList(BsonDocument.parse("{'$collStats': {'storageStats': { } } }"));
+  private static final List<BsonDocument> COLL_STATS_AGGREGATION_PIPELINE = asList(
+      BsonDocument.parse("{'$collStats': {'storageStats': { } } }"),
+      BsonDocument.parse(
+          "{'$project': {'size': '$storageStats.size', 'count': '$storageStats.count' } }"));
+  private static final List<BsonDocument> COLL_STATS_DATA_FEDERATION_AGGREGATION_PIPELINE = asList(
+      BsonDocument.parse("{'$collStats': {'count': { } } }"),
+      BsonDocument.parse(
+          "{'$group': {'_id': null, 'totalCount': {'$sum': '$count'} 'totalSize': {'$sum': '$partition.size' } } }"));
   private static final BsonDocument PING_COMMAND = BsonDocument.parse("{ping: 1}");
   public static final Partitioner SINGLE_PARTITIONER = new SinglePartitionPartitioner();
 
@@ -101,21 +108,41 @@ public static List<BsonDocument> createPartitionPipeline(
   public static BsonDocument storageStats(final ReadConfig readConfig) {
     LOGGER.info("Getting collection stats for: {}", readConfig.getNamespace().getFullName());
     try {
-      return readConfig
-          .withCollection(
-              coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_AGGREGATION_PIPELINE)
-                      .allowDiskUse(readConfig.getAggregationAllowDiskUse())
-                      .comment(readConfig.getComment())
-                      .first())
-                  .orElseGet(BsonDocument::new))
-          .getDocument("storageStats", new BsonDocument());
-    } catch (RuntimeException ex) {
-      if (ex instanceof MongoCommandException
-          && (ex.getMessage().contains("not found.")
-              || ((MongoCommandException) ex).getCode() == 26)) {
+      return readConfig.withCollection(
+          coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_AGGREGATION_PIPELINE)
+                  .allowDiskUse(readConfig.getAggregationAllowDiskUse())
+                  .comment(readConfig.getComment())
+                  .first())
+              .orElseGet(BsonDocument::new));
+    } catch (MongoCommandException ex) {
+      if (ex.getMessage().contains("not found.") || ex.getCode() == 26) {
         LOGGER.info("Could not find collection: {}", readConfig.getCollectionName());
         return new BsonDocument();
       }
+
+      // Atlas Data Federation does not support the storageStats property and requires
+      // special handling to return the federated collection stats.
+      if (ex.getMessage().contains("Data Federation") || ex.getCode() == 9) {
+        return storageStatsDataFederation(readConfig);
+      }
+      throw new MongoSparkException("Partitioner calling collStats command failed", ex);
+    } catch (RuntimeException ex) {
+      throw new MongoSparkException("Partitioner calling collStats command failed", ex);
+    }
+  }
+
+  private static BsonDocument storageStatsDataFederation(final ReadConfig readConfig) {
+    try {
+      return readConfig.withCollection(coll -> Optional.ofNullable(
+              coll.aggregate(COLL_STATS_DATA_FEDERATION_AGGREGATION_PIPELINE)
+                  .allowDiskUse(readConfig.getAggregationAllowDiskUse())
+                  .comment(readConfig.getComment())
+                  .map(collStats -> collStats
+                      .append("size", collStats.getNumber("totalSize", new BsonInt32(0)))
+                      .append("count", collStats.getNumber("totalCount", new BsonInt32(0))))
+                  .first())
+          .orElseGet(BsonDocument::new));
+    } catch (RuntimeException ex) {
       throw new MongoSparkException("Partitioner calling collStats command failed", ex);
     }
   }
@@ -138,5 +165,21 @@ public static List<String> getPreferredLocations(final ReadConfig readConfig) {
         .collect(Collectors.toList());
   }
 
+  /**
+   * Returns the average document size in a collection, either using {@code avgObjSize}
+   * or calculated from document count and collection size.
+   *
+   * @param storageStats the storage stats of a collection
+   * @return the average document size in a collection
+   */
+  public static double averageDocumentSize(final BsonDocument storageStats) {
+    if (storageStats.containsKey("avgObjSize")) {
+      return storageStats.get("avgObjSize", new BsonInt32(0)).asNumber().doubleValue();
+    }
+    double size = storageStats.getNumber("size", new BsonInt32(0)).doubleValue();
+    double count = storageStats.getNumber("count", new BsonInt32(0)).doubleValue();
+    return Math.floor(size / count);
+  }
+
   private PartitionerHelper() {}
 }
diff --git a/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/SamplePartitioner.java b/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/SamplePartitioner.java
index 88e13b42..8900e063 100644
--- a/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/SamplePartitioner.java
+++ b/src/main/java/com/mongodb/spark/sql/connector/read/partitioner/SamplePartitioner.java
@@ -34,7 +34,6 @@
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import org.bson.BsonDocument;
-import org.bson.BsonInt32;
 import org.bson.conversions.Bson;
 import org.jetbrains.annotations.ApiStatus;
 import org.jetbrains.annotations.NotNull;
@@ -105,8 +104,7 @@ public List<MongoInputPartition> generatePartitions(final ReadConfig readConfig)
       count = readConfig.withCollection(coll ->
           coll.countDocuments(matchQuery, new CountOptions().comment(readConfig.getComment())));
     }
-    double avgObjSizeInBytes =
-        storageStats.get("avgObjSize", new BsonInt32(0)).asNumber().doubleValue();
+    double avgObjSizeInBytes = PartitionerHelper.averageDocumentSize(storageStats);
     double numDocumentsPerPartition = Math.floor(partitionSizeInBytes / avgObjSizeInBytes);
 
     if (numDocumentsPerPartition >= count) {


Reviews:
user: login: guillotjulien
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T14:41:56Z
user: login: rozza
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T15:25:49Z
user: login: guillotjulien
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T06:09:24Z
user: login: rozza
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T13:45:56Z

Review comments:
diff_hunk: @@ -101,21 +107,43 @@ public static List<BsonDocument> createPartitionPipeline(
   public static BsonDocument storageStats(final ReadConfig readConfig) {
     LOGGER.info("Getting collection stats for: {}", readConfig.getNamespace().getFullName());
     try {
-      return readConfig
-          .withCollection(
-              coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_AGGREGATION_PIPELINE)
-                      .allowDiskUse(readConfig.getAggregationAllowDiskUse())
-                      .comment(readConfig.getComment())
-                      .first())
-                  .orElseGet(BsonDocument::new))
-          .getDocument("storageStats", new BsonDocument());
-    } catch (RuntimeException ex) {
-      if (ex instanceof MongoCommandException
-          && (ex.getMessage().contains("not found.")
-              || ((MongoCommandException) ex).getCode() == 26)) {
+      return readConfig.withCollection(
+          coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_AGGREGATION_PIPELINE)
+                  .allowDiskUse(readConfig.getAggregationAllowDiskUse())
+                  .comment(readConfig.getComment())
+                  .first())
+              .orElseGet(BsonDocument::new));
+    } catch (MongoCommandException ex) {
+      if (ex.getMessage().contains("not found.") || ex.getCode() == 26) {
         LOGGER.info("Could not find collection: {}", readConfig.getCollectionName());
         return new BsonDocument();
       }
+
+      // Atlas Data Federation does not support the storageStats property and requires
+      // special handling to return the federated collection stats.
+      if (ex.getMessage().contains("Data Federation") || ex.getCode() == 9) {
+        return storageStatsDataFederation(readConfig);
+      }
+      throw new MongoSparkException("Partitioner calling collStats command failed", ex);
+    } catch (RuntimeException ex) {
+      throw new MongoSparkException("Partitioner calling collStats command failed", ex);
+    }
+  }
+
+  private static BsonDocument storageStatsDataFederation(final ReadConfig readConfig) {
+    try {
+      return readConfig.withCollection(
+          coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_DATA_FEDERATION_AGGREGATION_PIPELINE)
+                  .allowDiskUse(readConfig.getAggregationAllowDiskUse())
+                  .comment(readConfig.getComment())
+                  .map(collStats -> collStats.append(
+                      "size",
+                      collStats
+                          .getDocument("partition", new BsonDocument())
+                          .getNumber("size", new BsonInt32(0))))
+                  .first())
path: src/main/java/com/mongodb/spark/sql/connector/read/partitioner/PartitionerHelper.java
user: login: guillotjulien
body: One issue with that approach is that when you have multiple partitions (S3 or Mongo clusters) backing up your federated collection, you'll only use the info from the first partition. Whereas the `collStats` command gives you the total size of all partitions.

Example:
```
[
  {
    ns: 'my_db.whatever',
    partition: {
      format: 'PARQUET',
      attributes: { hash: '26146' },
      size: 33190,
      source: 's3://whatever-bucket/_hash=26146/part-00006-f18185ed-bbfe-46d0-9772-d5fe80b2a1e8.c000.snappy.parquet?delimiter=%2F&region=eu-west-1'
    },
    count: 1
  },
  {
    ns: 'my_db.whatever',
    partition: {
      format: 'PARQUET',
      attributes: { hash: '26131' },
      size: 31093,
      source: 's3://whatever-bucket/_hash=26131/part-00005-28509ae5-f592-453b-a2e2-6e64bca01f27.c000.snappy.parquet?delimiter=%2F&region=eu-west-1'
    },
    count: 1
  },
  ...
]
```

I think this can be mitigated by summing the partition size and count of all partitions, then returning a `BsonDocument` from that.
created_at: 2025-04-24T14:41:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,21 +107,43 @@ public static List<BsonDocument> createPartitionPipeline(
   public static BsonDocument storageStats(final ReadConfig readConfig) {
     LOGGER.info("Getting collection stats for: {}", readConfig.getNamespace().getFullName());
     try {
-      return readConfig
-          .withCollection(
-              coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_AGGREGATION_PIPELINE)
-                      .allowDiskUse(readConfig.getAggregationAllowDiskUse())
-                      .comment(readConfig.getComment())
-                      .first())
-                  .orElseGet(BsonDocument::new))
-          .getDocument("storageStats", new BsonDocument());
-    } catch (RuntimeException ex) {
-      if (ex instanceof MongoCommandException
-          && (ex.getMessage().contains("not found.")
-              || ((MongoCommandException) ex).getCode() == 26)) {
+      return readConfig.withCollection(
+          coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_AGGREGATION_PIPELINE)
+                  .allowDiskUse(readConfig.getAggregationAllowDiskUse())
+                  .comment(readConfig.getComment())
+                  .first())
+              .orElseGet(BsonDocument::new));
+    } catch (MongoCommandException ex) {
+      if (ex.getMessage().contains("not found.") || ex.getCode() == 26) {
         LOGGER.info("Could not find collection: {}", readConfig.getCollectionName());
         return new BsonDocument();
       }
+
+      // Atlas Data Federation does not support the storageStats property and requires
+      // special handling to return the federated collection stats.
+      if (ex.getMessage().contains("Data Federation") || ex.getCode() == 9) {
+        return storageStatsDataFederation(readConfig);
+      }
+      throw new MongoSparkException("Partitioner calling collStats command failed", ex);
+    } catch (RuntimeException ex) {
+      throw new MongoSparkException("Partitioner calling collStats command failed", ex);
+    }
+  }
+
+  private static BsonDocument storageStatsDataFederation(final ReadConfig readConfig) {
+    try {
+      return readConfig.withCollection(
+          coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_DATA_FEDERATION_AGGREGATION_PIPELINE)
+                  .allowDiskUse(readConfig.getAggregationAllowDiskUse())
+                  .comment(readConfig.getComment())
+                  .map(collStats -> collStats.append(
+                      "size",
+                      collStats
+                          .getDocument("partition", new BsonDocument())
+                          .getNumber("size", new BsonInt32(0))))
+                  .first())
path: src/main/java/com/mongodb/spark/sql/connector/read/partitioner/PartitionerHelper.java
user: login: rozza
body: Thanks @guillotjulien - according to the [atlas docs](https://www.mongodb.com/docs/atlas/data-federation/supported-unsupported/pipeline/collstats/)

> The following example shows $collStats syntax for retrieving the total number of documents in the partitions.
> ```
> use s3Db
> db.abc.aggregate([ {$collStats: {"count" : {} }} ])
> ```

Have you found that is not the case?

created_at: 2025-04-24T15:25:49Z
author_association: MEMBER
diff_hunk: @@ -101,21 +107,43 @@ public static List<BsonDocument> createPartitionPipeline(
   public static BsonDocument storageStats(final ReadConfig readConfig) {
     LOGGER.info("Getting collection stats for: {}", readConfig.getNamespace().getFullName());
     try {
-      return readConfig
-          .withCollection(
-              coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_AGGREGATION_PIPELINE)
-                      .allowDiskUse(readConfig.getAggregationAllowDiskUse())
-                      .comment(readConfig.getComment())
-                      .first())
-                  .orElseGet(BsonDocument::new))
-          .getDocument("storageStats", new BsonDocument());
-    } catch (RuntimeException ex) {
-      if (ex instanceof MongoCommandException
-          && (ex.getMessage().contains("not found.")
-              || ((MongoCommandException) ex).getCode() == 26)) {
+      return readConfig.withCollection(
+          coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_AGGREGATION_PIPELINE)
+                  .allowDiskUse(readConfig.getAggregationAllowDiskUse())
+                  .comment(readConfig.getComment())
+                  .first())
+              .orElseGet(BsonDocument::new));
+    } catch (MongoCommandException ex) {
+      if (ex.getMessage().contains("not found.") || ex.getCode() == 26) {
         LOGGER.info("Could not find collection: {}", readConfig.getCollectionName());
         return new BsonDocument();
       }
+
+      // Atlas Data Federation does not support the storageStats property and requires
+      // special handling to return the federated collection stats.
+      if (ex.getMessage().contains("Data Federation") || ex.getCode() == 9) {
+        return storageStatsDataFederation(readConfig);
+      }
+      throw new MongoSparkException("Partitioner calling collStats command failed", ex);
+    } catch (RuntimeException ex) {
+      throw new MongoSparkException("Partitioner calling collStats command failed", ex);
+    }
+  }
+
+  private static BsonDocument storageStatsDataFederation(final ReadConfig readConfig) {
+    try {
+      return readConfig.withCollection(
+          coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_DATA_FEDERATION_AGGREGATION_PIPELINE)
+                  .allowDiskUse(readConfig.getAggregationAllowDiskUse())
+                  .comment(readConfig.getComment())
+                  .map(collStats -> collStats.append(
+                      "size",
+                      collStats
+                          .getDocument("partition", new BsonDocument())
+                          .getNumber("size", new BsonInt32(0))))
+                  .first())
path: src/main/java/com/mongodb/spark/sql/connector/read/partitioner/PartitionerHelper.java
user: login: guillotjulien
body: @rozza it does indeed, but it gives you one document per partition as shown in the example above. So if you want the total, you'd need to compute the sum of counts of all partitions.

e.g.
```
[
  { $collStats: { "count" : {} } },
  { $group: { _id: null, totalCount: { $sum: "$count" }, totalSize: { $sum: "$partition.size" } }  }
]
```
created_at: 2025-04-25T06:09:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,21 +107,43 @@ public static List<BsonDocument> createPartitionPipeline(
   public static BsonDocument storageStats(final ReadConfig readConfig) {
     LOGGER.info("Getting collection stats for: {}", readConfig.getNamespace().getFullName());
     try {
-      return readConfig
-          .withCollection(
-              coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_AGGREGATION_PIPELINE)
-                      .allowDiskUse(readConfig.getAggregationAllowDiskUse())
-                      .comment(readConfig.getComment())
-                      .first())
-                  .orElseGet(BsonDocument::new))
-          .getDocument("storageStats", new BsonDocument());
-    } catch (RuntimeException ex) {
-      if (ex instanceof MongoCommandException
-          && (ex.getMessage().contains("not found.")
-              || ((MongoCommandException) ex).getCode() == 26)) {
+      return readConfig.withCollection(
+          coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_AGGREGATION_PIPELINE)
+                  .allowDiskUse(readConfig.getAggregationAllowDiskUse())
+                  .comment(readConfig.getComment())
+                  .first())
+              .orElseGet(BsonDocument::new));
+    } catch (MongoCommandException ex) {
+      if (ex.getMessage().contains("not found.") || ex.getCode() == 26) {
         LOGGER.info("Could not find collection: {}", readConfig.getCollectionName());
         return new BsonDocument();
       }
+
+      // Atlas Data Federation does not support the storageStats property and requires
+      // special handling to return the federated collection stats.
+      if (ex.getMessage().contains("Data Federation") || ex.getCode() == 9) {
+        return storageStatsDataFederation(readConfig);
+      }
+      throw new MongoSparkException("Partitioner calling collStats command failed", ex);
+    } catch (RuntimeException ex) {
+      throw new MongoSparkException("Partitioner calling collStats command failed", ex);
+    }
+  }
+
+  private static BsonDocument storageStatsDataFederation(final ReadConfig readConfig) {
+    try {
+      return readConfig.withCollection(
+          coll -> Optional.ofNullable(coll.aggregate(COLL_STATS_DATA_FEDERATION_AGGREGATION_PIPELINE)
+                  .allowDiskUse(readConfig.getAggregationAllowDiskUse())
+                  .comment(readConfig.getComment())
+                  .map(collStats -> collStats.append(
+                      "size",
+                      collStats
+                          .getDocument("partition", new BsonDocument())
+                          .getNumber("size", new BsonInt32(0))))
+                  .first())
path: src/main/java/com/mongodb/spark/sql/connector/read/partitioner/PartitionerHelper.java
user: login: rozza
body: Thanks @guillotjulien I've added that to the latest commit.
created_at: 2025-04-28T13:45:56Z
author_association: MEMBER

PR comments:
user: login: rozza
created_at: 2025-04-24T13:02:15Z
body: @guillotjulien please check out this alternative to #132 
author_association: MEMBER
user: login: guillotjulien
created_at: 2025-04-29T11:54:45Z
body: Hi @rozza, I checked locally, and everything is working as expected. So LGTM thanks!
author_association: CONTRIBUTOR
user: login: rozza
created_at: 2025-05-08T08:33:28Z
body: @guillotjulien just to let you know 10.5.0 has been released and is on Maven central.
author_association: MEMBER
user: login: guillotjulien
created_at: 2025-05-08T11:31:41Z
body: Thanks for the fast release @rozza!
author_association: CONTRIBUTOR