Skip to content

Commit 2feaa26

Browse files
committed
Updates the Delta Lake source to support reading bounded change data
1 parent 72d7ae3 commit 2feaa26

6 files changed

Lines changed: 1051 additions & 1 deletion

File tree

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

0 commit comments

Comments
 (0)