Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2a61ac1
detect source table type instead of setting it manually in the config
Jun 18, 2026
d6174c4
test
Jun 18, 2026
4495f8a
fix test
Jun 18, 2026
9d6f389
fix imports in test
Jun 18, 2026
5830ee5
fix imports in test
Jun 18, 2026
a06de3f
fixes
Jun 19, 2026
1d528bb
spotless
Jun 19, 2026
096d0e9
fix for hadoop conf in SourceTable
Jun 19, 2026
843a358
fix for hadoop conf in SourceTable
Jun 19, 2026
ba7b1fd
fix for hadoop conf in SourceTable
Jun 19, 2026
c7aee87
fix for CI
Jun 19, 2026
e025289
fix for CI
Jun 19, 2026
416a38c
fix for CI
Jun 19, 2026
4cbb197
create a second sourceTable constructor to handle null format arg in…
Jun 19, 2026
3be1921
add hadoopConf param to sourceTable in ITConversionController
Jun 19, 2026
52f14d6
if many formats detected throw Exception
Jun 22, 2026
371a9fd
if many formats detected return first only
Jun 22, 2026
30a8d71
if many formats detected return first only
Jun 22, 2026
7448e48
if many formats detected throw exception: tested OK
Jun 23, 2026
6c54ced
fix CI
Jun 23, 2026
a2bfbd7
run utilities tests only to debug CI
Jun 23, 2026
9f9e7d1
run utilities tests only to debug CI
Jun 23, 2026
b870a31
remove throw when cannot load Iceberg table
Jun 23, 2026
0d0f154
private constructor to prevent instantiation
Jun 23, 2026
d2ca750
added support for detection over multi-synced table
Jul 7, 2026
c305cf8
Merge branch 'main' into detect_source_table
sapienza88 Jul 7, 2026
c74aecf
fix CI
Jul 8, 2026
56fb7cd
fix CI
Jul 8, 2026
0a131d9
test for detector with two successive syncs
Jul 14, 2026
be62bf5
test for detector with two successive syncs:add comments for the test
Jul 14, 2026
4934f78
test for detector with two successive syncs:add comments for the test
Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions xtable-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
<name>XTable Project API</name>

<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commons-lang3 added here but I don't see a usage in the diff — is it actually needed, or leftover?

</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
Expand Down Expand Up @@ -88,5 +92,37 @@
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>org.apache.iceberg</groupId>
<artifactId>iceberg-core</artifactId>
Comment thread
sapienza88 marked this conversation as resolved.
</dependency>
<dependency>
<groupId>org.apache.iceberg</groupId>
<artifactId>iceberg-api</artifactId>
</dependency>
<dependency>
<groupId>io.delta</groupId>
<artifactId>delta-core_2.12</artifactId>
</dependency>
<dependency>
<groupId>io.delta</groupId>
<artifactId>delta-core_2.12</artifactId>
</dependency>
<dependency>
<groupId>io.delta</groupId>
<artifactId>delta-core_2.12</artifactId>
</dependency>
<dependency>
<groupId>io.delta</groupId>
<artifactId>delta-core_2.12</artifactId>
</dependency>
<dependency>
<groupId>io.delta</groupId>
<artifactId>delta-kernel-api</artifactId>
</dependency>
<dependency>
<groupId>io.delta</groupId>
<artifactId>delta-kernel-defaults</artifactId>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import lombok.Getter;
import lombok.NonNull;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;

import com.google.common.base.Preconditions;
Expand All @@ -50,22 +51,26 @@ class ExternalTable {
/** Optional, additional properties that can be used to define interactions with the table */
protected final Properties additionalProperties;

protected final Configuration hadoopConf;

ExternalTable(
@NonNull String name,
@NonNull String formatName,
@NonNull String basePath,
String[] namespace,
CatalogConfig catalogConfig,
Properties additionalProperties) {
Properties additionalProperties,
Configuration hadoopConf) {
this.name = name;
this.formatName = formatName;
this.basePath = sanitizeBasePath(basePath);
this.namespace = namespace;
this.catalogConfig = catalogConfig;
this.additionalProperties = additionalProperties;
this.hadoopConf = hadoopConf;
}

protected String sanitizeBasePath(String tableBasePath) {
protected static String sanitizeBasePath(String tableBasePath) {
Path path = new Path(tableBasePath);
Preconditions.checkArgument(path.isAbsolute(), "Table base path must be absolute");
if (path.isAbsoluteAndSchemeAuthorityNull()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@

package org.apache.xtable.conversion;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Properties;

import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NonNull;

import org.apache.hadoop.conf.Configuration;

@EqualsAndHashCode(callSuper = true)
@Getter
public class SourceTable extends ExternalTable {
Expand All @@ -39,8 +43,41 @@ public SourceTable(
String dataPath,
String[] namespace,
CatalogConfig catalogConfig,
Properties additionalProperties) {
super(name, formatName, basePath, namespace, catalogConfig, additionalProperties);
Properties additionalProperties,
Configuration hadoopConf) {
super(name, formatName, basePath, namespace, catalogConfig, additionalProperties, hadoopConf);
this.dataPath = dataPath == null ? this.getBasePath() : sanitizeBasePath(dataPath);
}

public static SourceTable withDetectedFormat(
String name,
String basePath,
String dataPath,
String[] namespace,
CatalogConfig catalogConfig,
Properties additionalProperties,
Configuration hadoopConf) {

Configuration resolvedConf = hadoopConf != null ? hadoopConf : new Configuration();
String detectedFormat = resolveFormatOrThrow(basePath, resolvedConf);

return SourceTable.builder()
.name(name)
.formatName(detectedFormat)
.basePath(basePath)
.dataPath(dataPath)
.namespace(namespace)
.catalogConfig(catalogConfig)
.additionalProperties(additionalProperties)
.hadoopConf(resolvedConf)
.build();
}

private static String resolveFormatOrThrow(String basePath, Configuration hadoopConf) {
Comment thread
sapienza88 marked this conversation as resolved.
try {
return SourceTableFormatDetector.detectFormat(basePath, hadoopConf);
} catch (IOException e) {
throw new UncheckedIOException("Failed to auto-detect source table format", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.apache.xtable.conversion;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import lombok.extern.log4j.Log4j2;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;

import org.apache.iceberg.exceptions.NoSuchTableException;
import org.apache.iceberg.hadoop.HadoopTables;

import io.delta.kernel.Snapshot;
import io.delta.kernel.defaults.engine.DefaultEngine;
import io.delta.kernel.engine.Engine;
import io.delta.kernel.internal.SnapshotImpl;

import org.apache.xtable.model.storage.TableFormat;

@Log4j2
public class SourceTableFormatDetector {
// private constructor to prevent instantiation
private SourceTableFormatDetector() {
throw new UnsupportedOperationException("This class cannot be instantiated");
}
// helper method to detect input format
public static String detectFormat(String pathStr, Configuration conf) throws IOException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public API method with no Javadoc (just an inline // helper comment) — please document the return value, the thrown exceptions, and the detection scope. Minor: the delta/hudi checks use the sanitized basePath, but HadoopTables.load(pathStr) uses the raw arg — use the sanitized path consistently.

String sanitizeBasePath = ExternalTable.sanitizeBasePath(pathStr);
Path basePath = new Path(sanitizeBasePath);
FileSystem fs = basePath.getFileSystem(conf);

List<String> matches = new ArrayList<>();

if (fs.exists(new Path(basePath, "_delta_log"))) {
matches.add(TableFormat.DELTA);
}

if (fs.exists(new Path(basePath, ".hoodie"))) {
matches.add(TableFormat.HUDI);
}
try {
HadoopTables tables = new HadoopTables(conf);
org.apache.iceberg.Table table = tables.load(pathStr);
if (table != null) {
matches.add(TableFormat.ICEBERG);
}
} catch (NoSuchTableException e) {
log.debug("No Iceberg table found at path: {}", pathStr);
} catch (Exception e) {
Comment thread
sapienza88 marked this conversation as resolved.
log.debug("Unexpected error while probing for Iceberg table at path: {}", pathStr, e);
}
if (matches.size() == 1) {
return matches.get(0);
}

if (matches.size() > 1) {
Comment thread
sapienza88 marked this conversation as resolved.
log.info(
"Multiple formats detected: {}. Resolving target sync vs original source...", matches);
return inferSourceFromSyncMetadata(basePath, fs, matches, conf);
}
throw new IllegalArgumentException("Unable to detect table format for path: " + pathStr);
}

// checks source format from a XTable target
private static String inferSourceFromSyncMetadata(
Path basePath, FileSystem fs, List<String> detectedFormats, Configuration conf) {
try {
// check Hudi metadata if present
if (detectedFormats.contains(TableFormat.HUDI)) {
// check XTable target for hudi property file
Path hoodieMeta = new Path(basePath, ".hoodie");
if (fs.exists(new Path(hoodieMeta, "hoodie.properties"))) {
return TableFormat.HUDI;
}
}

if (detectedFormats.contains(TableFormat.ICEBERG)) {
HadoopTables tables = new HadoopTables(conf);
org.apache.iceberg.Table table = tables.load(basePath.toString());
// check for target property tag during a sync run
if (table.properties().containsKey("xtable.conversion.target")) {
detectedFormats.remove(TableFormat.ICEBERG);
// remove Hudi if it was detected as potential source, as it is not the source
if (detectedFormats.contains(TableFormat.HUDI)) {
detectedFormats.remove(TableFormat.HUDI);
}
if (detectedFormats.size() == 1) {
return detectedFormats.get(0);
}
}
}
if (detectedFormats.contains(TableFormat.DELTA)) {
try {

Engine kernelEngine = DefaultEngine.create(conf);

io.delta.kernel.Table table =
io.delta.kernel.Table.forPath(kernelEngine, basePath.toString());
Snapshot snapshot = table.getLatestSnapshot(kernelEngine);

if (snapshot instanceof SnapshotImpl) {
Map<String, String> config = ((SnapshotImpl) snapshot).getMetadata().getConfiguration();

if (config != null && config.containsKey("xtable.conversion.target")) {
detectedFormats.remove(TableFormat.DELTA);
// remove Hudi if it was detected as potential source, as it is not the source
if (detectedFormats.contains(TableFormat.HUDI)) {
detectedFormats.remove(TableFormat.HUDI);
}
if (detectedFormats.size() == 1) {
return detectedFormats.get(0);
}
}
}
} catch (Exception e) {
log.debug(
"Failed to verify Delta log metadata via internal XTable engine at path: {}",
basePath,
e);
}
}
} catch (Exception e) {
log.warn("Failed to parse table metadata properties during conflict resolution step", e);
}

return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import lombok.EqualsAndHashCode;
import lombok.Getter;

import org.apache.hadoop.conf.Configuration;

@Getter
@EqualsAndHashCode(callSuper = true)
public class TargetTable extends ExternalTable {
Expand All @@ -39,8 +41,16 @@ public TargetTable(
String[] namespace,
CatalogConfig catalogConfig,
Duration metadataRetention,
Properties additionalProperties) {
super(name, formatName, basePath, namespace, catalogConfig, additionalProperties);
Properties additionalProperties,
Configuration hadoopConf) {
Comment thread
sapienza88 marked this conversation as resolved.
super(
name,
formatName,
basePath,
namespace,
catalogConfig,
additionalProperties,
hadoopConf != null ? hadoopConf : new Configuration());
this.metadataRetention =
metadataRetention == null ? Duration.of(7, ChronoUnit.DAYS) : metadataRetention;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,36 +21,39 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.apache.hadoop.conf.Configuration;
import org.junit.jupiter.api.Test;

public class TestExternalTable {
Configuration hadoopConf = new Configuration();

@Test
void sanitizePath() {
ExternalTable tooManySlashes =
new ExternalTable("name", "hudi", "s3://bucket//path", null, null, null);
new ExternalTable("name", "hudi", "s3://bucket//path", null, null, null, hadoopConf);
assertEquals("s3://bucket/path", tooManySlashes.getBasePath());

ExternalTable localFilePath =
new ExternalTable("name", "hudi", "/local/data//path", null, null, null);
new ExternalTable("name", "hudi", "/local/data//path", null, null, null, hadoopConf);
assertEquals("file:///local/data/path", localFilePath.getBasePath());

ExternalTable properLocalFilePath =
new ExternalTable("name", "hudi", "file:///local/data//path", null, null, null);
new ExternalTable("name", "hudi", "file:///local/data//path", null, null, null, hadoopConf);
assertEquals("file:///local/data/path", properLocalFilePath.getBasePath());
}

@Test
void errorIfRequiredArgsNotSet() {
assertThrows(
NullPointerException.class,
() -> new ExternalTable("name", "hudi", null, null, null, null));
() -> new ExternalTable("name", "hudi", null, null, null, null, hadoopConf));

assertThrows(
NullPointerException.class,
() -> new ExternalTable("name", null, "file://bucket/path", null, null, null));
() -> new ExternalTable("name", null, "file://bucket/path", null, null, null, hadoopConf));

assertThrows(
NullPointerException.class,
() -> new ExternalTable(null, "hudi", "file://bucket/path", null, null, null));
() -> new ExternalTable(null, "hudi", "file://bucket/path", null, null, null, hadoopConf));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public static SourceTable convertToSourceTable(TargetTable table) {
table.getBasePath(),
table.getNamespace(),
table.getCatalogConfig(),
table.getAdditionalProperties());
table.getAdditionalProperties(),
table.hadoopConf);
}
}
Loading