Skip to content

Commit dac4722

Browse files
authored
[Iceberg CDC] Add Snapshot Changelog Scanner (#38836)
* add changelog scanner * sync and spotless * address comments
1 parent b2a2e45 commit dac4722

5 files changed

Lines changed: 1623 additions & 8 deletions

File tree

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
*/
1818
package org.apache.beam.sdk.io.iceberg;
1919

20+
import static org.apache.beam.sdk.io.iceberg.IcebergUtils.icebergSchemaToBeamSchema;
21+
import static org.apache.beam.sdk.util.Preconditions.checkArgumentNotNull;
2022
import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;
2123
import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
2224
import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkState;
@@ -26,6 +28,7 @@
2628
import java.io.Serializable;
2729
import java.util.ArrayList;
2830
import java.util.Collections;
31+
import java.util.Comparator;
2932
import java.util.HashSet;
3033
import java.util.LinkedHashSet;
3134
import java.util.List;
@@ -37,12 +40,15 @@
3740
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.MoreObjects;
3841
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
3942
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
43+
import org.apache.iceberg.StructLike;
4044
import org.apache.iceberg.Table;
4145
import org.apache.iceberg.TableUtil;
4246
import org.apache.iceberg.catalog.TableIdentifier;
4347
import org.apache.iceberg.expressions.Evaluator;
4448
import org.apache.iceberg.expressions.Expression;
49+
import org.apache.iceberg.types.Comparators;
4550
import org.apache.iceberg.types.TypeUtil;
51+
import org.apache.iceberg.util.SnapshotUtil;
4652
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
4753
import org.checkerframework.checker.nullness.qual.Nullable;
4854
import org.checkerframework.dataflow.qual.Pure;
@@ -54,6 +60,8 @@ public abstract class IcebergScanConfig implements Serializable {
5460
private transient org.apache.iceberg.@MonotonicNonNull Schema cachedProjectedSchema;
5561
private transient org.apache.iceberg.@MonotonicNonNull Schema cachedRequiredSchema;
5662
private transient @MonotonicNonNull Expression cachedFilter;
63+
private transient org.apache.iceberg.@MonotonicNonNull Schema cachedRecordIdSchema;
64+
private transient @MonotonicNonNull Schema cachedRowIdBeamSchema;
5765

5866
public enum ScanType {
5967
TABLE,
@@ -144,6 +152,25 @@ public org.apache.iceberg.Schema getRequiredSchema() {
144152
return cachedRequiredSchema;
145153
}
146154

155+
public org.apache.iceberg.Schema recordIdSchema() {
156+
if (cachedRecordIdSchema == null) {
157+
org.apache.iceberg.Schema fullSchema = getTable().schema();
158+
cachedRecordIdSchema = TypeUtil.select(fullSchema, fullSchema.identifierFieldIds());
159+
}
160+
return cachedRecordIdSchema;
161+
}
162+
163+
public Schema rowIdBeamSchema() {
164+
if (cachedRowIdBeamSchema == null) {
165+
cachedRowIdBeamSchema = icebergSchemaToBeamSchema(recordIdSchema());
166+
}
167+
return cachedRowIdBeamSchema;
168+
}
169+
170+
public Comparator<StructLike> recordIdComparator() {
171+
return Comparators.forType(recordIdSchema().asStruct());
172+
}
173+
147174
@Pure
148175
@Nullable
149176
public Evaluator getEvaluator(org.apache.iceberg.Schema requiredSchema) {
@@ -408,6 +435,28 @@ void validate(Table table) {
408435
getToTimestamp() == null || getToSnapshot() == null,
409436
error("only one of 'to_timestamp' or 'to_snapshot' can be set"));
410437

438+
@Nullable Long fromSnapshotId = ReadUtils.getFromSnapshotInclusive(table, this);
439+
@Nullable Long toSnapshotId = ReadUtils.getToSnapshot(table, this);
440+
if (fromSnapshotId != null) {
441+
checkArgumentNotNull(
442+
table.snapshot(fromSnapshotId),
443+
error("configured starting snapshot does not exist: '%s'"),
444+
fromSnapshotId);
445+
}
446+
if (toSnapshotId != null) {
447+
checkArgumentNotNull(
448+
table.snapshot(toSnapshotId),
449+
error("configured end snapshot does not exist: '%s'"),
450+
toSnapshotId);
451+
}
452+
if (fromSnapshotId != null && toSnapshotId != null) {
453+
checkArgument(
454+
SnapshotUtil.isAncestorOf(table, toSnapshotId, fromSnapshotId),
455+
error("fromSnapshot '%s' is not an ancestor of toSnapshot '%s'"),
456+
fromSnapshotId,
457+
toSnapshotId);
458+
}
459+
411460
if (getPollInterval() != null) {
412461
checkArgument(
413462
Boolean.TRUE.equals(getStreaming()),

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,30 +40,30 @@
4040
* refreshed after a caller started its request, the caller reuses that refresh instead of making
4141
* another catalog call.
4242
*/
43-
class TableCache {
43+
public class TableCache {
4444
static final Duration DEFAULT_REFRESH_INTERVAL = Duration.ofMinutes(2);
4545

4646
private static final Cache<CacheKey, CachedTable> TABLES =
4747
CacheBuilder.newBuilder().expireAfterAccess(1, TimeUnit.HOURS).build();
4848

4949
/** Returns the cached table, loading it from the catalog on a cache miss. */
50-
static Table get(IcebergCatalogConfig catalogConfig, TableIdentifier identifier) {
50+
public static Table get(IcebergCatalogConfig catalogConfig, TableIdentifier identifier) {
5151
return get(catalogConfig, identifier, () -> catalogConfig.catalog().loadTable(identifier));
5252
}
5353

5454
/** Returns the cached table for a string identifier, loading it on a cache miss. */
55-
static Table get(IcebergCatalogConfig catalogConfig, String identifier) {
55+
public static Table get(IcebergCatalogConfig catalogConfig, String identifier) {
5656
return get(catalogConfig, TableIdentifier.parse(identifier));
5757
}
5858

5959
/** Returns the cached table, using the given loader only on a cache miss. */
60-
static Table get(
60+
public static Table get(
6161
IcebergCatalogConfig catalogConfig, TableIdentifier identifier, Callable<Table> loader) {
6262
return getEntry(catalogConfig, identifier, loader).table;
6363
}
6464

6565
/** Returns the cached table after forcing a refresh of any pre-existing cache entry. */
66-
static Table getRefreshed(IcebergCatalogConfig catalogConfig, TableIdentifier identifier) {
66+
public static Table getRefreshed(IcebergCatalogConfig catalogConfig, TableIdentifier identifier) {
6767
Instant refreshRequestTime = Instant.now();
6868
CachedTable cachedTable =
6969
getEntry(catalogConfig, identifier, () -> catalogConfig.catalog().loadTable(identifier));
@@ -72,22 +72,26 @@ static Table getRefreshed(IcebergCatalogConfig catalogConfig, TableIdentifier id
7272
}
7373

7474
/** Returns the cached table for a string identifier after refreshing any pre-existing entry. */
75-
static Table getRefreshed(IcebergCatalogConfig catalogConfig, String identifier) {
75+
public static Table getRefreshed(IcebergCatalogConfig catalogConfig, String identifier) {
7676
return getRefreshed(catalogConfig, TableIdentifier.parse(identifier));
7777
}
7878

79+
public static Table getAndRefreshIfStale(IcebergCatalogConfig catalogConfig, String identifier) {
80+
return getAndRefreshIfStale(catalogConfig, TableIdentifier.parse(identifier));
81+
}
82+
7983
/**
8084
* Returns the cached table, refreshing it only if it is older than {@link
8185
* #DEFAULT_REFRESH_INTERVAL}.
8286
*/
83-
static Table getAndRefreshIfStale(
87+
public static Table getAndRefreshIfStale(
8488
IcebergCatalogConfig catalogConfig, TableIdentifier identifier) {
8589
return getAndRefreshIfStale(
8690
catalogConfig, identifier, () -> catalogConfig.catalog().loadTable(identifier));
8791
}
8892

8993
/** Returns the cached table, using the loader on a miss and refreshing stale entries. */
90-
static Table getAndRefreshIfStale(
94+
public static Table getAndRefreshIfStale(
9195
IcebergCatalogConfig catalogConfig, TableIdentifier identifier, Callable<Table> loader) {
9296
CachedTable cachedTable = getEntry(catalogConfig, identifier, loader);
9397
cachedTable.refreshIfOlderThan(Instant.now().minus(DEFAULT_REFRESH_INTERVAL));
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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.cdc;
19+
20+
import com.google.auto.value.AutoValue;
21+
import org.apache.beam.sdk.schemas.AutoValueSchema;
22+
import org.apache.beam.sdk.schemas.Schema;
23+
import org.apache.beam.sdk.schemas.SchemaCoder;
24+
import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
25+
import org.apache.beam.sdk.schemas.annotations.SchemaFieldNumber;
26+
import org.apache.beam.sdk.values.Row;
27+
import org.apache.beam.sdk.values.TypeDescriptor;
28+
import org.checkerframework.checker.nullness.qual.Nullable;
29+
30+
/**
31+
* Descriptor for a set of {@link SerializableChangelogTask}s.
32+
*
33+
* <p>This carries commit-sourced metadata for all rows produced from the task group. These values
34+
* are not read from data files; they are appended to final CDC output rows by {@link
35+
* CdcOutputUtils#outputRow} when requested.
36+
*/
37+
@DefaultSchema(AutoValueSchema.class)
38+
@AutoValue
39+
public abstract class ChangelogDescriptor {
40+
public static Builder builder() {
41+
return new AutoValue_ChangelogDescriptor.Builder();
42+
}
43+
44+
@SuppressWarnings("nullness")
45+
public static SchemaCoder<ChangelogDescriptor> coder(Schema overlapSchema) {
46+
Schema descriptorSchema =
47+
Schema.builder()
48+
.addStringField("tableIdentifierString")
49+
.addInt64Field("snapshotSequenceNumber")
50+
.addInt64Field("commitSnapshotId")
51+
.addNullableField("overlapLower", Schema.FieldType.row(overlapSchema))
52+
.addNullableField("overlapUpper", Schema.FieldType.row(overlapSchema))
53+
.build();
54+
55+
return SchemaCoder.of(
56+
descriptorSchema,
57+
TypeDescriptor.of(ChangelogDescriptor.class),
58+
descriptor ->
59+
Row.withSchema(descriptorSchema)
60+
.addValues(
61+
descriptor.getTableIdentifierString(),
62+
descriptor.getSnapshotSequenceNumber(),
63+
descriptor.getCommitSnapshotId(),
64+
descriptor.getOverlapLower(),
65+
descriptor.getOverlapUpper())
66+
.build(),
67+
row ->
68+
ChangelogDescriptor.builder()
69+
.setTableIdentifierString(row.getString("tableIdentifierString"))
70+
.setSnapshotSequenceNumber(row.getInt64("snapshotSequenceNumber"))
71+
.setCommitSnapshotId(row.getInt64("commitSnapshotId"))
72+
.setOverlapLower(row.getRow("overlapLower"))
73+
.setOverlapUpper(row.getRow("overlapUpper"))
74+
.build());
75+
}
76+
77+
@SchemaFieldNumber("0")
78+
public abstract String getTableIdentifierString();
79+
80+
@SchemaFieldNumber("1")
81+
public abstract long getSnapshotSequenceNumber();
82+
83+
@SchemaFieldNumber("2")
84+
public abstract long getCommitSnapshotId();
85+
86+
@SchemaFieldNumber("3")
87+
public abstract @Nullable Row getOverlapLower();
88+
89+
@SchemaFieldNumber("4")
90+
public abstract @Nullable Row getOverlapUpper();
91+
92+
@AutoValue.Builder
93+
public abstract static class Builder {
94+
abstract Builder setTableIdentifierString(String table);
95+
96+
abstract Builder setSnapshotSequenceNumber(long sequenceNumber);
97+
98+
abstract Builder setCommitSnapshotId(long snapshotId);
99+
100+
abstract Builder setOverlapLower(@Nullable Row overlapLower);
101+
102+
abstract Builder setOverlapUpper(@Nullable Row overlapUpper);
103+
104+
abstract ChangelogDescriptor build();
105+
}
106+
}

0 commit comments

Comments
 (0)