|
| 1 | +/* |
| 2 | + * Licensed to the Apache Software Foundation (ASF) under one |
| 3 | + * or more contributor license agreements. See the NOTICE file |
| 4 | + * distributed with this work for additional information |
| 5 | + * regarding copyright ownership. The ASF licenses this file |
| 6 | + * to you under the Apache License, Version 2.0 (the |
| 7 | + * "License"); you may not use this file except in compliance |
| 8 | + * with the License. You may obtain a copy of the License at |
| 9 | + * |
| 10 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 11 | + * |
| 12 | + * Unless required by applicable law or agreed to in writing, software |
| 13 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 14 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 15 | + * See the License for the specific language governing permissions and |
| 16 | + * limitations under the License. |
| 17 | + */ |
| 18 | +package org.apache.beam.sdk.io.iceberg.maintenance; |
| 19 | + |
| 20 | +import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull; |
| 21 | + |
| 22 | +import java.util.ArrayList; |
| 23 | +import java.util.Collections; |
| 24 | +import java.util.HashSet; |
| 25 | +import java.util.List; |
| 26 | +import java.util.Set; |
| 27 | +import java.util.UUID; |
| 28 | +import org.apache.beam.sdk.coders.KvCoder; |
| 29 | +import org.apache.beam.sdk.coders.VarIntCoder; |
| 30 | +import org.apache.beam.sdk.io.iceberg.FilterUtils; |
| 31 | +import org.apache.beam.sdk.io.iceberg.SnapshotInfo; |
| 32 | +import org.apache.beam.sdk.metrics.Counter; |
| 33 | +import org.apache.beam.sdk.metrics.Distribution; |
| 34 | +import org.apache.beam.sdk.metrics.Metrics; |
| 35 | +import org.apache.beam.sdk.schemas.NoSuchSchemaException; |
| 36 | +import org.apache.beam.sdk.schemas.SchemaCoder; |
| 37 | +import org.apache.beam.sdk.schemas.SchemaRegistry; |
| 38 | +import org.apache.beam.sdk.transforms.DoFn; |
| 39 | +import org.apache.beam.sdk.transforms.PTransform; |
| 40 | +import org.apache.beam.sdk.transforms.ParDo; |
| 41 | +import org.apache.beam.sdk.values.KV; |
| 42 | +import org.apache.beam.sdk.values.PCollection; |
| 43 | +import org.apache.beam.sdk.values.TupleTag; |
| 44 | +import org.apache.beam.sdk.values.TupleTagList; |
| 45 | +import org.apache.iceberg.FileScanTask; |
| 46 | +import org.apache.iceberg.ScanTaskGroup; |
| 47 | +import org.apache.iceberg.SerializableTable; |
| 48 | +import org.apache.iceberg.actions.BinPackRewriteFilePlanner; |
| 49 | +import org.apache.iceberg.expressions.Expression; |
| 50 | +import org.apache.iceberg.expressions.Expressions; |
| 51 | +import org.apache.iceberg.io.CloseableIterator; |
| 52 | +import org.apache.iceberg.util.TableScanUtil; |
| 53 | +import org.slf4j.Logger; |
| 54 | +import org.slf4j.LoggerFactory; |
| 55 | + |
| 56 | +/** |
| 57 | + * Scans the table once with Iceberg's native {@link BinPackRewriteFilePlanner} and produces |
| 58 | + * bin-pack rewrite groups, keyed by a "commit key" so downstream commits can be batched. |
| 59 | + * |
| 60 | + * <p><b>Intra-group parallelism.</b> Each planned group is split into <i>subgroups</i> by packing |
| 61 | + * its files' row-group <b>ranges</b> into bins of ~one target-sized output file (via {@code |
| 62 | + * inputSplitSize}), so a large group is rewritten by many workers in parallel while undersized |
| 63 | + * files combine into target-sized outputs. All subgroups of a parent share its commit key and |
| 64 | + * commit together as one batch: a range-split file can span subgroups, so committing the parent |
| 65 | + * atomically ensures a shared file is never partially replaced. |
| 66 | + * |
| 67 | + * <p><b>Convergence caveats.</b> A group may leave one remainder bin below {@code |
| 68 | + * min-file-size-bytes} — it converges on a later run or stays as one stable small file. A |
| 69 | + * single-row-group file whose size sits in the rewrite band cannot be split further and is |
| 70 | + * rewritten ~1:1. Delete-heavy groups shrink once their deletes are applied, converging on the |
| 71 | + * following run. Spec-changing rewrites ({@code output-spec-id} / post-evolution) fan a file out |
| 72 | + * across output partitions, bounded by {@link WriterFactory}. |
| 73 | + */ |
| 74 | +class PlanRewriteGroups |
| 75 | + extends PTransform<PCollection<SnapshotInfo>, PCollection<KV<Integer, RewriteSubGroup>>> { |
| 76 | + /** Main output: the planned rewrite subgroups, keyed by commit key. */ |
| 77 | + static final TupleTag<KV<Integer, RewriteSubGroup>> GROUPS = new TupleTag<>() {}; |
| 78 | + |
| 79 | + static final SchemaCoder<RewriteSubGroup> SUB_GROUP_CODER; |
| 80 | + |
| 81 | + static { |
| 82 | + try { |
| 83 | + SUB_GROUP_CODER = SchemaRegistry.createDefault().getSchemaCoder(RewriteSubGroup.class); |
| 84 | + } catch (NoSuchSchemaException e) { |
| 85 | + throw new RuntimeException(e); |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + private final SerializableTable table; |
| 90 | + private final RewriteDataFilesConfig config; |
| 91 | + |
| 92 | + PlanRewriteGroups(SerializableTable table, RewriteDataFilesConfig config) { |
| 93 | + this.table = table; |
| 94 | + this.config = config; |
| 95 | + } |
| 96 | + |
| 97 | + @Override |
| 98 | + public PCollection<KV<Integer, RewriteSubGroup>> expand(PCollection<SnapshotInfo> input) { |
| 99 | + return input |
| 100 | + .apply( |
| 101 | + "Scan and Plan Rewrite Groups", |
| 102 | + ParDo.of(new ScanAndPlan(table, config)).withOutputTags(GROUPS, TupleTagList.empty())) |
| 103 | + .get(GROUPS) |
| 104 | + .setCoder(KvCoder.of(VarIntCoder.of(), SUB_GROUP_CODER)); |
| 105 | + } |
| 106 | + |
| 107 | + static class ScanAndPlan extends DoFn<SnapshotInfo, KV<Integer, RewriteSubGroup>> { |
| 108 | + private static final Logger LOG = LoggerFactory.getLogger(ScanAndPlan.class); |
| 109 | + private final SerializableTable table; |
| 110 | + private final RewriteDataFilesConfig config; |
| 111 | + |
| 112 | + private static final Counter plannedGroups = |
| 113 | + Metrics.counter(PlanRewriteGroups.class, "plannedGroups"); |
| 114 | + private static final Counter plannedPartitionsToRewrite = |
| 115 | + Metrics.counter(PlanRewriteGroups.class, "plannedPartitionsToRewrite"); |
| 116 | + private static final Counter plannedFilesToRewrite = |
| 117 | + Metrics.counter(PlanRewriteGroups.class, "plannedFilesToRewrite"); |
| 118 | + private static final Counter plannedBytesToRewrite = |
| 119 | + Metrics.counter(PlanRewriteGroups.class, "plannedBytesToRewrite"); |
| 120 | + // Size of each DISTINCT planned input file |
| 121 | + private static final Distribution fileByteSizeToRewrite = |
| 122 | + Metrics.distribution(PlanRewriteGroups.class, "fileByteSizeToRewrite"); |
| 123 | + |
| 124 | + ScanAndPlan(SerializableTable table, RewriteDataFilesConfig config) { |
| 125 | + this.table = table; |
| 126 | + this.config = config; |
| 127 | + } |
| 128 | + |
| 129 | + @ProcessElement |
| 130 | + public void process(@Element SnapshotInfo element, MultiOutputReceiver out) { |
| 131 | + // An explicit snapshot id always wins. |
| 132 | + long startSnap = |
| 133 | + config.getSnapshotId() != null ? config.getSnapshotId() : element.getSnapshotId(); |
| 134 | + // The starting snapshot's sequence number floors the commit's idempotency-stamp scan, which |
| 135 | + // keeps working even if that snapshot is later expired. |
| 136 | + long startSequenceNumber = |
| 137 | + checkStateNotNull( |
| 138 | + table.snapshot(startSnap), "Starting snapshot %s not found in table", startSnap) |
| 139 | + .sequenceNumber(); |
| 140 | + // Carried in each group: names and tags the output files, and stamps commits for idempotency. |
| 141 | + String operationId = UUID.randomUUID().toString(); |
| 142 | + Expression filter = |
| 143 | + config.getFilter() != null |
| 144 | + ? FilterUtils.convert(config.getFilter(), table.schema()) |
| 145 | + : Expressions.alwaysTrue(); |
| 146 | + |
| 147 | + BinPackRewriteFilePlanner planner = |
| 148 | + new BinPackRewriteFilePlanner(table, filter, startSnap, config.caseSensitive()); |
| 149 | + planner.init( |
| 150 | + config.getRewriteOptions() != null ? config.getRewriteOptions() : Collections.emptyMap()); |
| 151 | + |
| 152 | + long totalRunningBytes = 0L; |
| 153 | + long maxRewriteBytes = config.maxRewriteBytes(); |
| 154 | + |
| 155 | + Set<String> plannedFiles = new HashSet<>(); |
| 156 | + Set<String> partitionPaths = new HashSet<>(); |
| 157 | + int plannedGroupIndex = 0; // running index among KEPT parents |
| 158 | + int globalIndex = 0; |
| 159 | + // Emit each kept parent's subgroups as the planner produces them. Every group shares one |
| 160 | + // commit key, so the whole rewrite commits as a single atomic batch. |
| 161 | + try (CloseableIterator<org.apache.iceberg.actions.RewriteFileGroup> it = |
| 162 | + planner.plan().groups().iterator()) { |
| 163 | + while (it.hasNext()) { |
| 164 | + org.apache.iceberg.actions.RewriteFileGroup group = it.next(); |
| 165 | + long groupBytes = group.inputFilesSizeInBytes(); |
| 166 | + // Skip groups that would push us over the byte budget; a smaller later group may fit. |
| 167 | + if (totalRunningBytes + groupBytes > maxRewriteBytes) { |
| 168 | + continue; |
| 169 | + } |
| 170 | + totalRunningBytes += groupBytes; |
| 171 | + |
| 172 | + String partitionPath = table.spec().partitionToPath(group.info().partition()); |
| 173 | + partitionPaths.add(partitionPath); |
| 174 | + int commitKey = 0; |
| 175 | + |
| 176 | + // A planned parent group covers several target output files, so split it into subgroups |
| 177 | + // by packing its files' row-group RANGES into bins of ~one target-sized output. |
| 178 | + // parentSubgroupCount is the number of bins emitted, which the commit stage uses to |
| 179 | + // verify completeness before replacing the parent's input files. |
| 180 | + List<ScanTaskGroup<FileScanTask>> bins = |
| 181 | + planSubGroupBins(group.fileScanTasks(), group.inputSplitSize()); |
| 182 | + int parentSubgroupCount = bins.size(); |
| 183 | + for (ScanTaskGroup<FileScanTask> bin : bins) { |
| 184 | + globalIndex++; |
| 185 | + List<FileScanTask> subTasks = new ArrayList<>(bin.tasks()); |
| 186 | + RewriteSubGroup beamGroup = |
| 187 | + RewriteSubGroup.builder() |
| 188 | + .setGlobalIndex(globalIndex) |
| 189 | + .setParentGroupIndex(plannedGroupIndex) |
| 190 | + .setParentSubgroupCount(parentSubgroupCount) |
| 191 | + .setFileScanTasks(subTasks, table.specs()) |
| 192 | + .setOutputSpecId(group.outputSpecId()) |
| 193 | + .setWriteMaxFileSize(group.maxOutputFileSize()) |
| 194 | + .setStartingSnapshotId(startSnap) |
| 195 | + .setStartingSequenceNumber(startSequenceNumber) |
| 196 | + .setOperationId(operationId) |
| 197 | + .build(); |
| 198 | + for (FileScanTask t : subTasks) { |
| 199 | + if (plannedFiles.add(t.file().location())) { |
| 200 | + fileByteSizeToRewrite.update(t.file().fileSizeInBytes()); |
| 201 | + } |
| 202 | + } |
| 203 | + out.get(GROUPS).output(KV.of(commitKey, beamGroup)); |
| 204 | + } |
| 205 | + plannedGroupIndex++; |
| 206 | + } |
| 207 | + } catch (Exception e) { |
| 208 | + throw new RuntimeException("Failed to plan rewrite groups", e); |
| 209 | + } |
| 210 | + |
| 211 | + if (plannedGroupIndex == 0) { |
| 212 | + LOG.info("No rewrite groups planned for snapshot {}.", startSnap); |
| 213 | + return; |
| 214 | + } |
| 215 | + |
| 216 | + LOG.info( |
| 217 | + "Planned {} rewrite group(s) -> {} parallel subgroup(s) across {} partition(s) for " |
| 218 | + + "snapshot {}.", |
| 219 | + plannedGroupIndex, |
| 220 | + globalIndex, |
| 221 | + partitionPaths.size(), |
| 222 | + startSnap); |
| 223 | + |
| 224 | + plannedGroups.inc(plannedGroupIndex); |
| 225 | + plannedFilesToRewrite.inc(plannedFiles.size()); |
| 226 | + plannedBytesToRewrite.inc(totalRunningBytes); |
| 227 | + plannedPartitionsToRewrite.inc(partitionPaths.size()); |
| 228 | + } |
| 229 | + |
| 230 | + /** |
| 231 | + * Packs a planned group's files into subgroup bins. Each file is split by its row-group |
| 232 | + * <b>ranges</b>, the ranges are bin-packed to {@code splitSize} capacity, and adjacent ranges |
| 233 | + * of the same file within a bin are merged back into one contiguous range. Undersized files |
| 234 | + * therefore combine into target-sized outputs while a large file is spread across several bins. |
| 235 | + * |
| 236 | + * <p>A file's ranges landing in several bins is safe: parent-group atomicity (see {@link |
| 237 | + * CommitRewriteGroups}) commits all of a parent's subgroups together, so a shared input file is |
| 238 | + * deleted only once every bin that read part of it has committed. A dropped or failed bin drops |
| 239 | + * the whole parent, orphaning its output files. |
| 240 | + */ |
| 241 | + private static List<ScanTaskGroup<FileScanTask>> planSubGroupBins( |
| 242 | + List<FileScanTask> tasks, long splitSize) { |
| 243 | + long effectiveSplitSize = Math.max(1L, splitSize); |
| 244 | + return TableScanUtil.planTaskGroups( |
| 245 | + tasks, effectiveSplitSize, /* lookback= */ 10, /* openFileCost= */ 0L); |
| 246 | + } |
| 247 | + } |
| 248 | +} |
0 commit comments