Skip to content

Commit f65e0e0

Browse files
authored
Updates the Delta Lake source to support reading bounded change data (#39426)
1 parent 525780e commit f65e0e0

7 files changed

Lines changed: 1692 additions & 87 deletions

File tree

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
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.delta;
19+
20+
import io.delta.kernel.CommitRange;
21+
import io.delta.kernel.CommitRangeBuilder;
22+
import io.delta.kernel.Scan;
23+
import io.delta.kernel.Snapshot;
24+
import io.delta.kernel.Table;
25+
import io.delta.kernel.TableManager;
26+
import io.delta.kernel.data.ColumnarBatch;
27+
import io.delta.kernel.data.Row;
28+
import io.delta.kernel.defaults.engine.DefaultEngine;
29+
import io.delta.kernel.engine.Engine;
30+
import io.delta.kernel.internal.DeltaLogActionUtils.DeltaAction;
31+
import io.delta.kernel.internal.TableImpl;
32+
import io.delta.kernel.internal.actions.AddCDCFile;
33+
import io.delta.kernel.internal.actions.AddFile;
34+
import io.delta.kernel.internal.util.VectorUtils;
35+
import io.delta.kernel.utils.CloseableIterator;
36+
import java.time.Instant;
37+
import java.util.ArrayList;
38+
import java.util.Collections;
39+
import java.util.HashMap;
40+
import java.util.HashSet;
41+
import java.util.List;
42+
import java.util.Map;
43+
import java.util.Set;
44+
import org.apache.beam.sdk.transforms.DoFn;
45+
import org.apache.hadoop.conf.Configuration;
46+
import org.checkerframework.checker.nullness.qual.Nullable;
47+
48+
/** A DoFn that reads the Delta log and plans read tasks for Change Data Feed. */
49+
class CreateCDCReadTasksDoFn extends DoFn<String, DeltaCDCReadTask> {
50+
private static final long MAX_TASK_SIZE_BYTES = 1024L * 1024L * 1024L; // 1 GB
51+
private final @Nullable Map<String, String> hadoopConfig;
52+
private final @Nullable Long startVersion;
53+
private final @Nullable String startTimestamp;
54+
private final @Nullable Long endVersion;
55+
private final @Nullable String endTimestamp;
56+
57+
public CreateCDCReadTasksDoFn(
58+
@Nullable Map<String, String> hadoopConfig,
59+
@Nullable Long startVersion,
60+
@Nullable String startTimestamp,
61+
@Nullable Long endVersion,
62+
@Nullable String endTimestamp) {
63+
this.hadoopConfig = hadoopConfig;
64+
this.startVersion = startVersion;
65+
this.startTimestamp = startTimestamp;
66+
this.endVersion = endVersion;
67+
this.endTimestamp = endTimestamp;
68+
}
69+
70+
@ProcessElement
71+
public void processElement(@Element String tablePath, OutputReceiver<DeltaCDCReadTask> out)
72+
throws Exception {
73+
Configuration conf = new Configuration();
74+
if (hadoopConfig != null) {
75+
for (Map.Entry<String, String> entry : hadoopConfig.entrySet()) {
76+
conf.set(entry.getKey(), entry.getValue());
77+
}
78+
}
79+
Engine engine = DefaultEngine.create(conf);
80+
Table table = Table.forPath(engine, tablePath);
81+
TableImpl tableImpl = (TableImpl) table;
82+
83+
// 1. Resolve starting and ending versions
84+
long resolvedStartVersion;
85+
if (startVersion != null) {
86+
resolvedStartVersion = startVersion;
87+
} else if (startTimestamp != null) {
88+
long startMillis = Instant.parse(startTimestamp).toEpochMilli();
89+
resolvedStartVersion = tableImpl.getVersionAtOrAfterTimestamp(engine, startMillis);
90+
} else {
91+
throw new IllegalArgumentException("Starting version or timestamp must be specified.");
92+
}
93+
94+
long resolvedEndVersion;
95+
if (endVersion != null) {
96+
resolvedEndVersion = endVersion;
97+
} else if (endTimestamp != null) {
98+
long endMillis = Instant.parse(endTimestamp).toEpochMilli();
99+
resolvedEndVersion = tableImpl.getVersionBeforeOrAtTimestamp(engine, endMillis);
100+
} else {
101+
resolvedEndVersion = table.getLatestSnapshot(engine).getVersion();
102+
}
103+
104+
if (resolvedStartVersion > resolvedEndVersion) {
105+
throw new IllegalArgumentException(
106+
String.format(
107+
"Resolved start version %d is greater than resolved end version %d",
108+
resolvedStartVersion, resolvedEndVersion));
109+
}
110+
111+
// 2. Load snapshot at resolvedEndVersion to get the scanStateRow
112+
// We use endVersion's schema because it represents the latest schema in the
113+
// read range
114+
// which handles schema evolution (older files will just lack new columns).
115+
Snapshot endSnapshot = table.getSnapshotAsOfVersion(engine, resolvedEndVersion);
116+
Scan scan = endSnapshot.getScanBuilder().build();
117+
Row scanState = scan.getScanState(engine);
118+
SerializableRow serializableScanState = new SerializableRow(scanState);
119+
120+
// 3. Load snapshot at resolvedStartVersion to initialize the CommitRange
121+
Snapshot startSnapshot = table.getSnapshotAsOfVersion(engine, resolvedStartVersion);
122+
123+
CommitRangeBuilder rangeBuilder =
124+
TableManager.loadCommitRange(
125+
tablePath, CommitRangeBuilder.CommitBoundary.atVersion(resolvedStartVersion));
126+
rangeBuilder.withEndBoundary(CommitRangeBuilder.CommitBoundary.atVersion(resolvedEndVersion));
127+
CommitRange range = rangeBuilder.build(engine);
128+
129+
// We need both CDC and ADD actions.
130+
// If a commit version has CDC files, we only read CDC files.
131+
// If a commit version has no CDC files, we read ADD files (inserts).
132+
Set<DeltaAction> actionSet = new HashSet<>();
133+
actionSet.add(DeltaAction.CDC);
134+
actionSet.add(DeltaAction.ADD);
135+
136+
// 4. Iterate over commits in the range and group actions by version
137+
try (CloseableIterator<ColumnarBatch> batchIter =
138+
range.getActions(engine, startSnapshot, actionSet)) {
139+
Map<Long, CommitActionsInfo> commitActionsMap = new HashMap<>();
140+
141+
while (batchIter.hasNext()) {
142+
ColumnarBatch batch = batchIter.next();
143+
int versionIdx = batch.getSchema().indexOf("version");
144+
int timestampIdx = batch.getSchema().indexOf("timestamp");
145+
int cdcIdx = batch.getSchema().indexOf("cdc");
146+
int addIdx = batch.getSchema().indexOf("add");
147+
148+
for (int i = 0; i < batch.getSize(); i++) {
149+
long version = batch.getColumnVector(versionIdx).getLong(i);
150+
long timestamp = batch.getColumnVector(timestampIdx).getLong(i);
151+
152+
CommitActionsInfo info =
153+
commitActionsMap.computeIfAbsent(
154+
version, k -> new CommitActionsInfo(version, timestamp));
155+
156+
if (cdcIdx >= 0 && !batch.getColumnVector(cdcIdx).isNullAt(i)) {
157+
Row cdcRow =
158+
(Row)
159+
VectorUtils.getValueAsObject(
160+
batch.getColumnVector(cdcIdx),
161+
batch.getSchema().at(cdcIdx).getDataType(),
162+
i);
163+
info.cdcInfo.add(cdcRow);
164+
}
165+
if (addIdx >= 0 && !batch.getColumnVector(addIdx).isNullAt(i)) {
166+
Row addRow =
167+
(Row)
168+
VectorUtils.getValueAsObject(
169+
batch.getColumnVector(addIdx),
170+
batch.getSchema().at(addIdx).getDataType(),
171+
i);
172+
AddFile addFile = new AddFile(addRow);
173+
// Only consider add files that change data (ignore OPTIMIZE etc.)
174+
if (addFile.getDataChange()) {
175+
info.insertInfo.add(addRow);
176+
}
177+
}
178+
}
179+
}
180+
181+
// 5. Emit tasks for each version
182+
List<DeltaCDCReadTask> currentGroup = new ArrayList<>();
183+
long currentGroupSize = 0L;
184+
185+
// TODO: to prevent OOMs in true streaming executions, update DeltaReadTask to include a group
186+
// of files.
187+
188+
// Sort versions to process them in order
189+
List<Long> versions = new ArrayList<>(commitActionsMap.keySet());
190+
Collections.sort(versions);
191+
192+
for (long version : versions) {
193+
CommitActionsInfo info = commitActionsMap.get(version);
194+
if (info == null) {
195+
throw new IllegalStateException("CommitActionsInfo was not found for version " + version);
196+
}
197+
boolean hasCDC = !info.cdcInfo.isEmpty();
198+
199+
List<Row> rowsToProcess = hasCDC ? info.cdcInfo : info.insertInfo;
200+
201+
for (Row fileRow : rowsToProcess) {
202+
String relPath;
203+
long size;
204+
Map<String, String> partitionValues;
205+
206+
if (hasCDC) {
207+
relPath = fileRow.getString(AddCDCFile.FULL_SCHEMA.indexOf("path"));
208+
size = fileRow.getLong(AddCDCFile.FULL_SCHEMA.indexOf("size"));
209+
partitionValues =
210+
VectorUtils.toJavaMap(
211+
fileRow.getMap(AddCDCFile.FULL_SCHEMA.indexOf("partitionValues")));
212+
} else {
213+
AddFile addFile = new AddFile(fileRow);
214+
relPath = addFile.getPath();
215+
size = addFile.getSize();
216+
partitionValues = VectorUtils.toJavaMap(addFile.getPartitionValues());
217+
}
218+
219+
String fullPath = new org.apache.hadoop.fs.Path(tablePath, relPath).toString();
220+
List<Long> rowGroupSizes = getRowGroupSizes(fullPath, conf);
221+
222+
DeltaCDCReadTask task =
223+
new DeltaCDCReadTask(
224+
fullPath,
225+
size,
226+
partitionValues,
227+
info.version,
228+
info.timestamp,
229+
hasCDC,
230+
rowGroupSizes,
231+
serializableScanState);
232+
233+
if (size >= MAX_TASK_SIZE_BYTES) {
234+
if (!currentGroup.isEmpty()) {
235+
emitGroup(currentGroup, out);
236+
currentGroup = new ArrayList<>();
237+
currentGroupSize = 0L;
238+
}
239+
out.output(task);
240+
} else {
241+
if (currentGroupSize + size > MAX_TASK_SIZE_BYTES) {
242+
emitGroup(currentGroup, out);
243+
currentGroup = new ArrayList<>();
244+
currentGroup.add(task);
245+
currentGroupSize = size;
246+
} else {
247+
currentGroup.add(task);
248+
currentGroupSize += size;
249+
}
250+
}
251+
}
252+
}
253+
254+
if (!currentGroup.isEmpty()) {
255+
emitGroup(currentGroup, out);
256+
}
257+
}
258+
}
259+
260+
private void emitGroup(List<DeltaCDCReadTask> group, OutputReceiver<DeltaCDCReadTask> out) {
261+
for (DeltaCDCReadTask task : group) {
262+
out.output(task);
263+
}
264+
}
265+
266+
private List<Long> getRowGroupSizes(String pathStr, Configuration conf) {
267+
List<Long> sizes = new ArrayList<>();
268+
try {
269+
org.apache.hadoop.fs.Path hadoopPath = new org.apache.hadoop.fs.Path(pathStr);
270+
org.apache.parquet.hadoop.metadata.ParquetMetadata metadata =
271+
org.apache.parquet.hadoop.ParquetFileReader.readFooter(
272+
conf,
273+
hadoopPath,
274+
org.apache.parquet.format.converter.ParquetMetadataConverter.NO_FILTER);
275+
for (org.apache.parquet.hadoop.metadata.BlockMetaData block : metadata.getBlocks()) {
276+
sizes.add(block.getTotalByteSize());
277+
}
278+
} catch (java.io.IOException e) {
279+
throw new RuntimeException("Failed to read Parquet footer for " + pathStr, e);
280+
}
281+
return sizes;
282+
}
283+
284+
private static class CommitActionsInfo {
285+
final long version;
286+
287+
final long timestamp;
288+
final List<Row> cdcInfo = new ArrayList<>();
289+
final List<Row> insertInfo = new ArrayList<>();
290+
291+
CommitActionsInfo(long version, long timestamp) {
292+
this.version = version;
293+
this.timestamp = timestamp;
294+
}
295+
}
296+
}

0 commit comments

Comments
 (0)