morningman commented on code in PR #69076:
URL: https://github.com/apache/doris/pull/69076#discussion_r4277175779


##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/RewriteManifestExecutor.java:
##########
@@ -113,13 +119,27 @@ public Result execute(Table table, Integer specId) {
         }
     }
 
+    /**
+     * Groups data files by the value of their first partition field (one 
group for an unpartitioned table), as
+     * Trino's optimize_manifests does, so a rewrite of a multi-field 
partition does not write one manifest per
+     * partition. The key holds the value PartitionData returns (immutable, or 
a copy for binary values) rather
+     * than the partition, so it does not depend on whether ManifestReader 
reuses the partition between rows (its
+     * iterator() copies files for that reason); the list lets a NULL value be 
a key, which Iceberg rejects.
+     */
+    private static Object clusterKey(DataFile file) {
+        StructLike partition = file.partition();
+        return partition.size() == 0
+                ? Collections.emptyList()
+                : Collections.singletonList(partition.get(0, Object.class));

Review Comment:
   Correcting my earlier reply, which was wrong about Trino. Trino's 
`optimize_manifests` no longer keys `clusterBy` by every value of the first 
partition field: trinodb/trino de101509c3 bounded the keys to avoid running the 
coordinator out of memory, and 4a58ac208e reads the field's distinct values 
from the manifests, sorts them and cuts them into at most `min(ceil(total 
manifest size / commit.manifest.target-size-bytes), 200)` runs of adjacent 
values. So the writers can be bounded without writing the manifests ourselves.
   
   a1cea007a99 does the same here. For each targeted partition spec, the 
executor reads the distinct values of the spec's first partition field from the 
targeted manifests (only the `partition` column, in parallel on Iceberg's 
worker pool), sorts them with NULL first, and gives the value at rank i cluster 
`i * clusters / n`, where `clusters = min(ceil(size of the spec's targeted 
manifests / target size), 200)`. Adjacent partitions share a manifest, so the 
manifests keep narrow partition summaries, and the rewrite keeps at most that 
many writers open per spec. A spec whose targeted manifests fit in one 
target-size manifest is one cluster and is not read ahead, so such a table now 
gets one manifest instead of one per value. Unlike Trino, each spec is 
clustered on its own first field rather than the current spec's, because 
`spec_id` can target an older spec, and the first fields of two specs can be of 
different types.
   
   Tests: 
`IcebergRewriteManifestsActionTest.clustersAdjacentFirstPartitionValues` (files 
appended with 100, 1, 50 and 7 become the runs {1, 7} and {50, 100}), 
`clustersEachPartitionSpecByItsOwnFirstField`, 
`clusterCountIsTheManifestSizeOverTheTargetUpTo200`, 
`assignsSortedValuesToRunsOfNearlyEqualLength` and 
`mergesPartitionedManifestsThatFitTheTargetSizeIntoOne`. Not sorting, dropping 
the cap, rounding up in a way that overflows, clustering every spec on the 
current spec's first field, and sorting without NULL first each turn one of 
them red. On a local cluster, a table partitioned by `a` with four INSERTs of 
100, 1, 50 and 7, and `commit.manifest.target-size-bytes` set to half the size 
of its four manifests, returns `[4, 2]`, and the two manifests cover 1 to 7 and 
50 to 100.
   



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/RewriteManifestExecutor.java:
##########
@@ -66,60 +78,66 @@ public Result execute(Table table, Integer specId) {
             }
 
             // Collect manifests before rewrite and filter by specId if 
provided
-            List<ManifestFile> manifestsBefore = 
currentSnapshot.dataManifests(table.io());
-            List<ManifestFile> manifestsBeforeTargeted = 
filterBySpecId(manifestsBefore, specId);
-
-            int rewrittenCount = manifestsBeforeTargeted.size();
-
-            if (rewrittenCount == 0) {
+            List<ManifestFile> manifestsBeforeTargeted =
+                    filterBySpecId(currentSnapshot.dataManifests(table.io()), 
specId);
+            if (manifestsBeforeTargeted.isEmpty()) {
                 return new Result(0, 0);
             }
-
-            // Configure rewrite operation, optionally restricting manifests 
by specId
-            RewriteManifests rm = table.rewriteManifests();
-
-            if (specId != null) {
-                final int targetSpecId = specId;
-                rm.rewriteIf(manifest -> manifest.partitionSpecId() == 
targetSpecId);
-            }
-
-            // Commit manifest rewrite
-            rm.commit();
-
-            // Refresh snapshot after rewrite
-            Snapshot snapshotAfter = table.currentSnapshot();
-            if (snapshotAfter == null) {
-                return new Result(rewrittenCount, 0);
+            // One manifest below the target size has nothing to merge with, 
as in Spark's and Trino's manifest
+            // rewrites: leave the table alone instead of committing a 
snapshot that changes nothing.
+            long targetManifestSizeBytes = 
PropertyUtil.propertyAsLong(table.properties(),
+                    TableProperties.MANIFEST_TARGET_SIZE_BYTES, 
TableProperties.MANIFEST_TARGET_SIZE_BYTES_DEFAULT);
+            if (manifestsBeforeTargeted.size() == 1
+                    && manifestsBeforeTargeted.get(0).length() < 
targetManifestSizeBytes) {
+                return new Result(0, 0);
             }
 
-            // Collect manifests after rewrite and filter by specId
-            List<ManifestFile> manifestsAfter = 
snapshotAfter.dataManifests(table.io());
-            List<ManifestFile> manifestsAfterTargeted = 
filterBySpecId(manifestsAfter, specId);
-
-            // Compute addedCount as newly produced manifests (path not in 
before set)
-            java.util.Set<String> beforePaths = 
manifestsBeforeTargeted.stream()
-                    .map(ManifestFile::path)
-                    .collect(java.util.stream.Collectors.toSet());
-
-            int addedCount = (int) manifestsAfterTargeted.stream()
+            // Without a cluster function Iceberg rewrites nothing 
(BaseRewriteManifests.requiresRewrite) and only
+            // commits a snapshot that keeps every manifest. Only the targeted 
manifests are rewritten: a manifest
+            // that another writer commits before the rewrite applies is kept 
as it is. Such a manifest can be of a
+            // partition spec added after the table was loaded, which the 
rewrite cannot write, because it takes
+            // the specs of the table it is created from.
+            Set<String> targetedPaths = manifestsBeforeTargeted.stream()
                     .map(ManifestFile::path)
-                    .filter(path -> !beforePaths.contains(path))
-                    .count();
+                    .collect(Collectors.toSet());
+            RewriteManifests rm = table.rewriteManifests()
+                    .clusterBy(RewriteManifestExecutor::clusterKey)

Review Comment:
   Not changing this. The mismatch is inside Iceberg's `BaseRewriteManifests`, 
in 1.11.0 and on Iceberg main alike: the constructor keeps 
`ops().current().specsById()` for its manifest writers, while `performRewrite` 
reads the manifests with the specs of the metadata that 
`SnapshotProducer.apply()` has just refreshed. Any caller that builds the 
rewrite from a loaded table and then commits it has this window; Trino's 
`optimize_manifests` builds it from a loaded `BaseTable` in the same way.
   
   I reproduced it against Iceberg 1.11.0 with the executor's calls. After 
another handle promotes the identity source column from `int` to `long`, 
`commit()` fails with `IllegalArgumentException: Wrong class, expected 
java.lang.Integer, but was java.lang.Long`. With a1cea007a99, a spec whose 
targeted manifests are larger than the target size reads its first partition 
values ahead, with the specs of the loaded table, so the rewrite's readers then 
return `Long` values that are not among them, and the executor fails first, 
with `the table's partitioning changed during the rewrite`. 
`IcebergRewriteManifestsActionTest.failsBeforeCommitWhenTheFirstPartitionFieldIsPromotedDuringTheRewrite`
 pins that case: the rewrite fails, and the snapshot and the manifests stay as 
they were. Running the procedure again on a freshly loaded table rewrites them.
   
   The window runs from loading the table to the first refresh inside 
`commit()`: one manifest-list read, plus that read-ahead for such a spec. A 
type promotion of a partition source column has to land in it, and the 
procedure then fails before it commits, with an error that names the cause. The 
alternatives on our side are a retry around the SDK commit, or leaving out the 
manifests whose partition type changed. Here the second one would leave out 
every targeted manifest, so the procedure would commit a snapshot that changes 
nothing and report `[0, 0]`. I prefer the failure. The fix belongs in 
`BaseRewriteManifests`: its writers should take their specs from the refreshed 
metadata, as its readers do.
   



##########
fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/RewriteManifestExecutor.java:
##########
@@ -66,60 +78,66 @@ public Result execute(Table table, Integer specId) {
             }
 
             // Collect manifests before rewrite and filter by specId if 
provided
-            List<ManifestFile> manifestsBefore = 
currentSnapshot.dataManifests(table.io());
-            List<ManifestFile> manifestsBeforeTargeted = 
filterBySpecId(manifestsBefore, specId);
-
-            int rewrittenCount = manifestsBeforeTargeted.size();
-
-            if (rewrittenCount == 0) {
+            List<ManifestFile> manifestsBeforeTargeted =
+                    filterBySpecId(currentSnapshot.dataManifests(table.io()), 
specId);
+            if (manifestsBeforeTargeted.isEmpty()) {
                 return new Result(0, 0);
             }
-
-            // Configure rewrite operation, optionally restricting manifests 
by specId
-            RewriteManifests rm = table.rewriteManifests();
-
-            if (specId != null) {
-                final int targetSpecId = specId;
-                rm.rewriteIf(manifest -> manifest.partitionSpecId() == 
targetSpecId);
-            }
-
-            // Commit manifest rewrite
-            rm.commit();
-
-            // Refresh snapshot after rewrite
-            Snapshot snapshotAfter = table.currentSnapshot();
-            if (snapshotAfter == null) {
-                return new Result(rewrittenCount, 0);
+            // One manifest below the target size has nothing to merge with, 
as in Spark's and Trino's manifest
+            // rewrites: leave the table alone instead of committing a 
snapshot that changes nothing.
+            long targetManifestSizeBytes = 
PropertyUtil.propertyAsLong(table.properties(),
+                    TableProperties.MANIFEST_TARGET_SIZE_BYTES, 
TableProperties.MANIFEST_TARGET_SIZE_BYTES_DEFAULT);
+            if (manifestsBeforeTargeted.size() == 1
+                    && manifestsBeforeTargeted.get(0).length() < 
targetManifestSizeBytes) {
+                return new Result(0, 0);
             }
 
-            // Collect manifests after rewrite and filter by specId
-            List<ManifestFile> manifestsAfter = 
snapshotAfter.dataManifests(table.io());
-            List<ManifestFile> manifestsAfterTargeted = 
filterBySpecId(manifestsAfter, specId);
-
-            // Compute addedCount as newly produced manifests (path not in 
before set)
-            java.util.Set<String> beforePaths = 
manifestsBeforeTargeted.stream()
-                    .map(ManifestFile::path)
-                    .collect(java.util.stream.Collectors.toSet());
-
-            int addedCount = (int) manifestsAfterTargeted.stream()
+            // Without a cluster function Iceberg rewrites nothing 
(BaseRewriteManifests.requiresRewrite) and only
+            // commits a snapshot that keeps every manifest. Only the targeted 
manifests are rewritten: a manifest
+            // that another writer commits before the rewrite applies is kept 
as it is. Such a manifest can be of a
+            // partition spec added after the table was loaded, which the 
rewrite cannot write, because it takes
+            // the specs of the table it is created from.
+            Set<String> targetedPaths = manifestsBeforeTargeted.stream()
                     .map(ManifestFile::path)
-                    .filter(path -> !beforePaths.contains(path))
-                    .count();
+                    .collect(Collectors.toSet());
+            RewriteManifests rm = table.rewriteManifests()
+                    .clusterBy(RewriteManifestExecutor::clusterKey)
+                    .rewriteIf(manifest -> 
targetedPaths.contains(manifest.path()));
+            rm.commit();
 
-            return new Result(rewrittenCount, addedCount);
+            // Count the manifests replaced and written by the snapshot this 
rewrite committed. Iceberg builds the
+            // event of that snapshot after the commit 
(SnapshotProducer.notifyListeners), while the table's current
+            // snapshot can already be another one.
+            Map<String, String> summary = ((CreateSnapshotEvent) 
rm.updateEvent()).summary();

Review Comment:
   Not changing this, although the sequence is real. I reproduced it against 
Iceberg 1.11.0 with table operations that let another writer fast append and 
then expire our snapshot right after our commit. `commit()` returns, and 
`rm.updateEvent()` then throws `NullPointerException: Cannot invoke 
"org.apache.iceberg.Snapshot.sequenceNumber()" because "snapshot" is null`.
   
   The two counts exist only in the summary of that snapshot, so once it is 
expired there is nothing exact left to report. Iceberg's own 
`CreateSnapshotEvent` and commit report for this commit fail in the same way 
(`SnapshotProducer.notifyListeners` only logs it), and Trino's 
`optimize_manifests`, which reads the counts from the commit report, returns 
none in this case. Our result columns are non-nullable INTs, so returning 
success would mean making the counts up, for example by comparing the manifest 
lists before and after the commit, which the first round of this review showed 
to be wrong under a concurrent commit.
   
   The window is between our metadata commit and the refresh that 
`SnapshotProducer.commit()` makes right after it. In it another writer has to 
commit an append and then expire a snapshot that is a few milliseconds old; by 
default `expire_snapshots` keeps the snapshots of the last five days. The 
rewrite only replaces manifests, so the table's data is the same either way, 
and running the procedure again rewrites the current manifests.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to