Skip to content

Commit eb91fac

Browse files
committed
planner stuff
1 parent 45b1630 commit eb91fac

5 files changed

Lines changed: 1195 additions & 1 deletion

File tree

sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/FilterUtils.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ private static void extractFieldNames(SqlNode node, Set<String> fieldNames) {
132132
* parses a SQL filter expression string into an Iceberg {@link Expression} that can be used for
133133
* data pruning.
134134
*/
135-
static Expression convert(@Nullable String filter, Schema schema) {
135+
public static Expression convert(@Nullable String filter, Schema schema) {
136136
if (filter == null) {
137137
return Expressions.alwaysTrue();
138138
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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

Comments
 (0)