diff --git a/lance-flink-1.18/pom.xml b/lance-flink-1.18/pom.xml index e066132..5c02314 100644 --- a/lance-flink-1.18/pom.xml +++ b/lance-flink-1.18/pom.xml @@ -80,6 +80,29 @@ flink-connector-base + + + org.apache.hadoop + hadoop-common + 3.3.6 + provided + + + org.slf4j + slf4j-log4j12 + + + log4j + log4j + + + org.slf4j + slf4j-reload4j + + + + org.slf4j diff --git a/lance-flink-1.19/pom.xml b/lance-flink-1.19/pom.xml index bc03c9f..12b7d1c 100644 --- a/lance-flink-1.19/pom.xml +++ b/lance-flink-1.19/pom.xml @@ -80,6 +80,29 @@ flink-connector-base + + + org.apache.hadoop + hadoop-common + 3.3.6 + provided + + + org.slf4j + slf4j-log4j12 + + + log4j + log4j + + + org.slf4j + slf4j-reload4j + + + + org.slf4j diff --git a/lance-flink-1.20/pom.xml b/lance-flink-1.20/pom.xml index d99b43e..36204b7 100644 --- a/lance-flink-1.20/pom.xml +++ b/lance-flink-1.20/pom.xml @@ -80,6 +80,29 @@ flink-connector-base + + + org.apache.hadoop + hadoop-common + 3.3.6 + provided + + + org.slf4j + slf4j-log4j12 + + + log4j + log4j + + + org.slf4j + slf4j-reload4j + + + + org.slf4j diff --git a/pom.xml b/pom.xml index c2baa6c..cdfbef1 100644 --- a/pom.xml +++ b/pom.xml @@ -323,10 +323,16 @@ - - org.apache.arrow - org.apache.flink.connector.lance.shaded.arrow - + diff --git a/src/main/java/org/apache/flink/connector/lance/LanceSource.java b/src/main/java/org/apache/flink/connector/lance/LanceSource.java index ac72582..12d1614 100644 --- a/src/main/java/org/apache/flink/connector/lance/LanceSource.java +++ b/src/main/java/org/apache/flink/connector/lance/LanceSource.java @@ -128,7 +128,7 @@ public void open(Configuration parameters) throws Exception { Path path = Paths.get(datasetPath); try { // Honor read.version / read.as-of-timestamp for time-travel reads (issue #5). - this.dataset = LanceOpener.open(path.toString(), allocator, options); + this.dataset = LanceOpener.open(path.toString(), allocator, options, parameters); } catch (Exception e) { throw new IOException("Cannot open Lance dataset: " + datasetPath, e); } diff --git a/src/main/java/org/apache/flink/connector/lance/config/LanceOptions.java b/src/main/java/org/apache/flink/connector/lance/config/LanceOptions.java index 3e10ac9..ea872a6 100644 --- a/src/main/java/org/apache/flink/connector/lance/config/LanceOptions.java +++ b/src/main/java/org/apache/flink/connector/lance/config/LanceOptions.java @@ -25,7 +25,9 @@ import java.io.Serializable; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; /** @@ -394,6 +396,7 @@ public static MetricType fromValue(String value) { private final Integer vectorRefineFactor; private final String defaultDatabase; private final String warehouse; + private final Map hadoopConfig; private LanceOptions(Builder builder) { this.path = builder.path; @@ -421,6 +424,9 @@ private LanceOptions(Builder builder) { this.vectorRefineFactor = builder.vectorRefineFactor; this.defaultDatabase = builder.defaultDatabase; this.warehouse = builder.warehouse; + this.hadoopConfig = builder.hadoopConfig == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(builder.hadoopConfig)); } // ==================== Getter Methods ==================== @@ -525,6 +531,16 @@ public String getWarehouse() { return warehouse; } + /** + * Extra Hadoop configuration key/value pairs (e.g. {@code tbdsfs.meta}) that should be + * injected into the Hadoop {@link org.apache.hadoop.conf.Configuration} used to resolve + * Hadoop-family dataset paths (tbdsfs/hdfs). Populated from SQL {@code WITH} options + * prefixed with {@code hadoop.}. + */ + public Map getHadoopConfig() { + return hadoopConfig; + } + // ==================== Builder ==================== public static Builder builder() { @@ -631,6 +647,7 @@ public static class Builder { private Integer vectorRefineFactor; private String defaultDatabase = "default"; private String warehouse; + private Map hadoopConfig; public Builder path(String path) { this.path = path; @@ -757,6 +774,11 @@ public Builder warehouse(String warehouse) { return this; } + public Builder hadoopConfig(Map hadoopConfig) { + this.hadoopConfig = hadoopConfig; + return this; + } + /** * Build LanceOptions instance with validation */ diff --git a/src/main/java/org/apache/flink/connector/lance/table/LanceDynamicTableFactory.java b/src/main/java/org/apache/flink/connector/lance/table/LanceDynamicTableFactory.java index 364d9a2..585a6d5 100644 --- a/src/main/java/org/apache/flink/connector/lance/table/LanceDynamicTableFactory.java +++ b/src/main/java/org/apache/flink/connector/lance/table/LanceDynamicTableFactory.java @@ -28,7 +28,9 @@ import org.apache.flink.table.factories.DynamicTableSourceFactory; import org.apache.flink.table.factories.FactoryUtil; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; /** @@ -187,10 +189,11 @@ public Set> optionalOptions() { @Override public DynamicTableSource createDynamicTableSource(Context context) { FactoryUtil.TableFactoryHelper helper = FactoryUtil.createTableFactoryHelper(this, context); - helper.validate(); + Map tableOptions = context.getCatalogTable().getOptions(); + helper.validateExcept(extractHadoopOptionKeys(tableOptions)); ReadableConfig config = helper.getOptions(); - LanceOptions options = buildLanceOptions(config); + LanceOptions options = buildLanceOptions(config, tableOptions); return new LanceDynamicTableSource( options, @@ -201,10 +204,11 @@ public DynamicTableSource createDynamicTableSource(Context context) { @Override public DynamicTableSink createDynamicTableSink(Context context) { FactoryUtil.TableFactoryHelper helper = FactoryUtil.createTableFactoryHelper(this, context); - helper.validate(); + Map tableOptions = context.getCatalogTable().getOptions(); + helper.validateExcept(extractHadoopOptionKeys(tableOptions)); ReadableConfig config = helper.getOptions(); - LanceOptions options = buildLanceOptions(config); + LanceOptions options = buildLanceOptions(config, tableOptions); return new LanceDynamicTableSink( options, @@ -212,10 +216,22 @@ public DynamicTableSink createDynamicTableSink(Context context) { ); } + /** + * 提取以 {@code hadoop.} 为前缀的选项 key,供 {@code validateExcept} 跳过校验。 + */ + private String[] extractHadoopOptionKeys(Map tableOptions) { + if (tableOptions == null) { + return new String[0]; + } + return tableOptions.keySet().stream() + .filter(key -> key != null && key.startsWith("hadoop.")) + .toArray(String[]::new); + } + /** * Build LanceOptions from configuration */ - private LanceOptions buildLanceOptions(ReadableConfig config) { + private LanceOptions buildLanceOptions(ReadableConfig config, Map tableOptions) { LanceOptions.Builder builder = LanceOptions.builder(); // Common configuration @@ -248,6 +264,31 @@ private LanceOptions buildLanceOptions(ReadableConfig config) { builder.vectorMetric(LanceOptions.MetricType.fromValue(config.get(VECTOR_METRIC))); builder.vectorNprobes(config.get(VECTOR_NPROBES)); + // Hadoop-family FileSystem 配置(例如 hadoop.tbdsfs.meta),用于 tbdsfs/hdfs 路径解析 + Map hadoopConfig = extractHadoopConfig(tableOptions); + if (!hadoopConfig.isEmpty()) { + builder.hadoopConfig(hadoopConfig); + } + return builder.build(); } + + /** + * 从表 DDL 的原始 WITH 参数中提取以 {@code hadoop.} 为前缀的配置项,去掉前缀后作为 + * Hadoop Configuration 的 key/value(例如 {@code 'hadoop.tbdsfs.meta' = 'zk://...'} + * → {@code tbdsfs.meta = zk://...})。用于绕过集群部分节点 core-site.xml 缺配置的问题。 + */ + private Map extractHadoopConfig(Map tableOptions) { + Map result = new HashMap<>(); + if (tableOptions == null) { + return result; + } + for (Map.Entry entry : tableOptions.entrySet()) { + String key = entry.getKey(); + if (key != null && key.startsWith("hadoop.") && entry.getValue() != null) { + result.put(key.substring("hadoop.".length()), entry.getValue()); + } + } + return result; + } } diff --git a/src/main/java/org/apache/flink/connector/lance/table/LanceDynamicTableSource.java b/src/main/java/org/apache/flink/connector/lance/table/LanceDynamicTableSource.java index 0565102..98c1a39 100644 --- a/src/main/java/org/apache/flink/connector/lance/table/LanceDynamicTableSource.java +++ b/src/main/java/org/apache/flink/connector/lance/table/LanceDynamicTableSource.java @@ -141,6 +141,11 @@ LanceOptions buildRuntimeOptions(RowType rowType) { .readBatchSize(options.getReadBatchSize()) .readFilter(buildFilterExpression()); + // 携带 hadoop.* 配置(如 tbdsfs.meta),避免投影/过滤下推重建 options 时丢失 + if (options.getHadoopConfig() != null && !options.getHadoopConfig().isEmpty()) { + optionsBuilder.hadoopConfig(options.getHadoopConfig()); + } + // Carry over time-travel options from the SQL WITH clause (issue #5). // Without this the readVersion / readAsOfTimestamp get dropped when the planner // rebuilds options during projection/filter push-down. diff --git a/src/main/java/org/apache/flink/connector/lance/util/LanceHadoopPathResolver.java b/src/main/java/org/apache/flink/connector/lance/util/LanceHadoopPathResolver.java new file mode 100644 index 0000000..aa34115 --- /dev/null +++ b/src/main/java/org/apache/flink/connector/lance/util/LanceHadoopPathResolver.java @@ -0,0 +1,439 @@ +/* + * 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.flink.connector.lance.util; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.LocatedFileStatus; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.fs.RemoteIterator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 方案 B:Hadoop FileSystem 前置缓存层。 + * + *

Lance 底层通过 lance-core(Rust)直接读取存储,其 {@code object_store} 只支持 + * 一组硬编码 scheme({@code file/s3/gs/az/abfss/oss/cos/hf/memory})。对于 + * {@code tbdsfs://} / {@code hdfs://} 等 Hadoop 生态的 scheme,Lance 无法识别。 + * + *

本工具类的职责:在 {@code Dataset.open(uri)} 调用之前,如果 {@code uri} 是 + * Hadoop 兼容的 scheme(且不在 Lance 原生支持列表内),则: + *

    + *
  1. 通过 Hadoop {@link FileSystem} 打开源目录;
  2. + *
  3. 递归把整个 lance dataset 目录同步到本地临时缓存目录;
  4. + *
  5. 返回本地 {@code file:///...} 路径给 Lance。
  6. + *
+ * + *

使用条件:需要在 classpath 中提供对应 scheme 的 Hadoop {@code FileSystem} + * 实现(例如 tbdsfs 需要 {@code tbdsfs-hadoop-*.jar})。这些 jar 在 TBDS 集群的 + * {@code /usr/local/service/flink/lib/} 下由集群提供,因此 lance-flink 本身 + * 无需绑定。 + * + *

限制:当前实现为整目录 读时全量拷贝,对小/中等规模 lance dataset + * 有效;对超大 dataset 建议后续演进为 range-read 或增量同步策略。 + * + *

本类线程安全;对同一源路径的并发 resolve 请求会串行化为一次下载。 + */ +public final class LanceHadoopPathResolver { + + private static final Logger LOG = LoggerFactory.getLogger(LanceHadoopPathResolver.class); + + /** Lance native object_store 支持的 scheme,遇到这些直接透传。 */ + private static final Set LANCE_NATIVE_SCHEMES = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList( + "file", "s3", "s3+ddb", "gs", "az", "abfss", "oss", "cos", + "hf", "memory", "file+uring", "shared-memory", "file-object-store" + ))); + + /** 每个进程内所有已缓存的源 URI → 本地路径映射;同时兼作锁监视器。 */ + private static final ConcurrentHashMap CACHE_LOCKS = new ConcurrentHashMap<>(); + + /** + * Lance 的 latest 版本哨兵号({@code u64::MAX - 1})。Lance 在 {@code _versions/} 目录下 + * 用它标记"最新版本"的别名 manifest。该文件并非真实数据,Lance 在缺失时可通过扫描 + * {@code _versions/} 下的最大版本号确定 latest,因此对它读取失败时可安全跳过。 + * 部分 tbdsfs 后端会把这个哨兵 manifest 存坏(读时报 input/output error)。 + */ + private static final String LATEST_MANIFEST_SENTINEL = "18446744073709551614"; + + private LanceHadoopPathResolver() {} + + /** + * 解析 URI,如果需要则把远端 lance dataset 目录同步到本地缓存,返回可以直接 + * 传给 {@code Dataset.open()} 的路径。 + * + * @param sourceUri 用户配置的 lance dataset URI,可能是 + * {@code /local/path}、{@code file:///...}、{@code s3://...}、 + * {@code tbdsfs://...}、{@code hdfs://...} 等 + * @param hadoopConf Hadoop 配置(可为 {@code null},此时使用默认 {@link Configuration}) + * @param localCacheRoot 本地缓存根目录(可为 {@code null},此时使用系统默认 + * {@code java.io.tmpdir/lance-hadoop-cache}) + * @return 可直接传给 lance-core 的路径(本地路径或原路径透传) + */ + public static String resolveForRead(String sourceUri, Configuration hadoopConf, + String localCacheRoot) { + if (sourceUri == null || sourceUri.isEmpty()) { + return sourceUri; + } + // 规范化 URI:某些上游(如 Flink Table planner / Path 反序列化)会把 + // "scheme://authority/path" 收敛成 "scheme:/authority/path"(单斜杠), + // 导致 URI.getAuthority() 变为 null,进而丢失 tbdsfs 需要的 name。 + // 这里通过把 "scheme:/xxx"(且不是 "scheme:///")恢复为 "scheme://xxx"。 + sourceUri = normalizeUri(sourceUri); + + String scheme = extractScheme(sourceUri); + if (scheme == null || LANCE_NATIVE_SCHEMES.contains(scheme)) { + // 无 scheme(当作本地路径)或 lance 原生支持的 scheme,透传 + return sourceUri; + } + + // 需要走 Hadoop FS 缓存 + Configuration conf = buildHadoopConfiguration(hadoopConf); + applySchemeSpecificDefaults(conf, sourceUri, scheme); + java.nio.file.Path cacheRoot = resolveCacheRoot(localCacheRoot); + java.nio.file.Path targetDir = cacheRoot.resolve(sanitize(sourceUri)); + + // 串行化并发访问:同一个 sourceUri 只下载一次 + Object lock = CACHE_LOCKS.computeIfAbsent(sourceUri, k -> new Object()); + synchronized (lock) { + try { + if (isCacheReady(targetDir)) { + LOG.info("Lance dataset {} already cached at {}, reuse local copy", + sourceUri, targetDir); + } else { + LOG.info("Lance dataset {} not in native supported schemes ({}); " + + "downloading via Hadoop FileSystem to {}", + sourceUri, scheme, targetDir); + downloadDirectory(new Path(sourceUri), targetDir, conf); + } + return "file://" + targetDir.toAbsolutePath(); + } catch (IOException e) { + throw new UncheckedIOException( + "Failed to cache Lance dataset from " + sourceUri + + " to local " + targetDir + + ". Ensure the corresponding Hadoop FileSystem impl " + + "(e.g. tbdsfs-hadoop-*.jar) is available in classpath, " + + "and Kerberos credentials are valid.", + e); + } + } + } + + /** + * 构造 Hadoop {@link Configuration}: + *

    + *
  1. 如果用户传入 {@code userConf},以他为基础;否则新建一个(会自动 + * 加载 classpath 上的 {@code core-site.xml})。
  2. + *
  3. 扫描以 {@code lance.hadoop.} 为前缀的系统属性,去前缀后注入 Configuration。 + * 例如 {@code -Dlance.hadoop.fs.tbdsfs.impl=io.tbdsfs.TbdsFileSystem} 会被 + * 映射为 Hadoop 配置 {@code fs.tbdsfs.impl}。
  4. + *
  5. 扫描以 {@code LANCE_HADOOP_} 为前缀的环境变量,以下划线为分隔符。 + * 例如 {@code LANCE_HADOOP_TBDSFS_META=zk://host:port} 会被映射为 + * {@code tbdsfs.meta}。
  6. + *
+ * + *

处于 Flink TaskManager 环境中时,由于 YARN 容器不一定可以看到宿主机的 + * {@code core-site.xml},上述两种方式作为兽底的配置注入通道。 + */ + static Configuration buildHadoopConfiguration(Configuration userConf) { + Configuration conf = userConf != null ? userConf : new Configuration(); + + // 显式加载 core-site.xml / hdfs-site.xml。在 Flink YARN TaskManager 容器内, + // 由于 Flink 的 child-first 类加载器隔离,new Configuration() 未必能通过 + // context classloader 加载到宿主机的 core-site.xml,导致 tbdsfs.meta 等配置 + // 缺失(tbdsfs 的 Go 库会因此报 invalid uri 并 fatal)。这里按标准路径兜底加载。 + loadHadoopSiteXmls(conf); + + // 1. 从系统属性注入 lance.hadoop.* -> hadoop conf key + java.util.Properties props = System.getProperties(); + for (String key : props.stringPropertyNames()) { + if (key.startsWith("lance.hadoop.")) { + String hadoopKey = key.substring("lance.hadoop.".length()); + String value = props.getProperty(key); + conf.set(hadoopKey, value); + LOG.info("Injected Hadoop conf from system property: {} = {}", hadoopKey, value); + } + } + + // 2. 从环境变量注入 LANCE_HADOOP_XXX -> hadoop conf key + java.util.Map env = System.getenv(); + for (java.util.Map.Entry e : env.entrySet()) { + if (e.getKey().startsWith("LANCE_HADOOP_")) { + String hadoopKey = e.getKey().substring("LANCE_HADOOP_".length()) + .toLowerCase(Locale.ROOT).replace('_', '.'); + String value = e.getValue(); + conf.set(hadoopKey, value); + LOG.info("Injected Hadoop conf from environment variable: {} = {}", hadoopKey, value); + } + } + + return conf; + } + + /** + * 从 Flink 的全局配置构造 Hadoop {@link Configuration}。 + * + *

Flink SQL 里通过 {@code SET 'flink.hadoop.xxx' = 'yyy'} 设置的配置会以 + * {@code flink.hadoop.} 前缀进入 Flink 的 {@code Configuration}。这里把这些 + * 前缀剥掉后注入 Hadoop {@code Configuration}(例如 {@code flink.hadoop.tbdsfs.meta} + * → {@code tbdsfs.meta})。 + * + *

这解决了在 YARN TaskManager 容器内 {@code new Configuration()} 因类加载器 + * 隔离而加载不到宿主机的 {@code core-site.xml}(进而拿不到 {@code tbdsfs.meta}) + * 的问题——tbdsfs 的 Go 库在 {@code meta} 为空时会 fallback 到把 name 当 URI, + * 报 {@code invalid uri: /internal} 并直接 fatal 退出。 + * + * @param flinkConf Flink 运行时配置(可为 {@code null}) + * @return 注入了 {@code flink.hadoop.*} 配置的 Hadoop {@code Configuration} + */ + public static Configuration buildHadoopConfigurationFromFlink( + org.apache.flink.configuration.Configuration flinkConf) { + Configuration conf = new Configuration(); + if (flinkConf == null) { + return conf; + } + for (java.util.Map.Entry e : flinkConf.toMap().entrySet()) { + String key = e.getKey(); + if (key != null && key.startsWith("flink.hadoop.")) { + String hadoopKey = key.substring("flink.hadoop.".length()); + conf.set(hadoopKey, e.getValue()); + LOG.info("Injected Hadoop conf from Flink config: {} = {}", hadoopKey, e.getValue()); + } + } + return conf; + } + + /** + * 针对特定 scheme 应用兜底默认值: + *

    + *
  • {@code tbdsfs:///...}:tbdsfs 的 {@code TbdsFileSystemImpl.initialize} + * 要求 {@code tbdsfs.name} 配置存在。如果用户未显式配置且 URI 的 authority 非空, + * 则自动把 authority 作为 {@code tbdsfs.name} 注入。同时保证 + * {@code fs.tbdsfs.impl} 存在(默认 {@code io.tbdsfs.TbdsFileSystem})。
  • + *
+ * + *

该方法只做"缺省填充",永远不会覆盖用户已经显式设置的值。 + */ + static void applySchemeSpecificDefaults(Configuration conf, String sourceUri, String scheme) { + if (conf == null || scheme == null) return; + if (!"tbdsfs".equals(scheme)) return; + + // 1. 确保 fs.tbdsfs.impl 存在 + if (conf.get("fs.tbdsfs.impl") == null) { + conf.set("fs.tbdsfs.impl", "io.tbdsfs.TbdsFileSystem"); + LOG.info("Applied default fs.tbdsfs.impl = io.tbdsfs.TbdsFileSystem"); + } + + // 2. 如果 tbdsfs.name 未配置,从 URI authority 推导 + if (conf.get("tbdsfs.name") == null) { + try { + URI u = new URI(sourceUri); + String authority = u.getAuthority(); + if (authority != null && !authority.isEmpty()) { + conf.set("tbdsfs.name", authority); + LOG.info("Applied default tbdsfs.name = {} (from URI authority)", authority); + } + } catch (URISyntaxException ignore) { + // fall through; tbdsfs will raise its own error if truly missing + } + } + } + + /** + * 规范化 URI:如果输入形如 {@code scheme:/authority/path}(单斜杠,authority 与 path + * 之间没有明确分隔),将其转换为 {@code scheme://authority/path}(双斜杠)。 + * 常见触发场景是 Flink Table 反序列化 URI 时 {@code new Path(str)} 会丢失一个斜杠。 + * 幂等:对已经形如 {@code scheme://...} 或 {@code scheme:///...}(无 authority)的 + * URI 不做任何改动。 + */ + static String normalizeUri(String uri) { + if (uri == null) return null; + int colon = uri.indexOf(':'); + if (colon <= 0 || colon >= uri.length() - 1) return uri; + String rest = uri.substring(colon + 1); + // 如果已经是 "//..."(含空 authority 的 "///..."),保持原样 + if (rest.startsWith("//")) return uri; + // 只处理 "scheme:/xxx" 且第 2 个字符不是 '/'(否则已经是双斜杠了) + if (rest.startsWith("/") && !rest.startsWith("//")) { + String scheme = uri.substring(0, colon); + return scheme + ":/" + rest; // 把 "scheme:/xxx" 变为 "scheme://xxx" + } + return uri; + } + + /** 提取 scheme。返回小写 scheme,如果没有 scheme 返回 {@code null}。 */ + static String extractScheme(String uri) { + try { + URI u = new URI(uri); + String s = u.getScheme(); + return s == null ? null : s.toLowerCase(Locale.ROOT); + } catch (URISyntaxException e) { + // 非标 URI(比如 Windows 路径 C:\),当作本地路径 + return null; + } + } + + static boolean isNativeSupportedScheme(String scheme) { + return scheme != null && LANCE_NATIVE_SCHEMES.contains(scheme.toLowerCase(Locale.ROOT)); + } + + /** + * 显式加载 Hadoop 的 {@code core-site.xml} / {@code hdfs-site.xml}。 + * 用于兜底 Flink YARN 容器内 {@code new Configuration()} 因类加载器隔离而 + * 加载不到宿主机 site 文件的问题。 + */ + private static void loadHadoopSiteXmls(Configuration conf) { + java.nio.file.Path confDir = resolveHadoopConfDir(); + if (confDir == null) { + return; + } + addSiteXmlIfExists(conf, confDir.resolve("core-site.xml")); + addSiteXmlIfExists(conf, confDir.resolve("hdfs-site.xml")); + } + + private static void addSiteXmlIfExists(Configuration conf, java.nio.file.Path siteXml) { + if (Files.isRegularFile(siteXml)) { + conf.addResource(new Path(siteXml.toUri())); + LOG.info("Explicitly loaded Hadoop site config: {}", siteXml); + } + } + + /** 探测 Hadoop 配置目录:HADOOP_CONF_DIR → 系统属性 → TBDS 标准路径。 */ + private static java.nio.file.Path resolveHadoopConfDir() { + String env = System.getenv("HADOOP_CONF_DIR"); + if (env != null && !env.isEmpty()) { + return java.nio.file.Paths.get(env); + } + String prop = System.getProperty("hadoop.conf.dir"); + if (prop != null && !prop.isEmpty()) { + return java.nio.file.Paths.get(prop); + } + java.nio.file.Path standard = java.nio.file.Paths.get("/usr/local/service/hadoop/etc/hadoop"); + if (Files.isDirectory(standard)) { + return standard; + } + return null; + } + + private static java.nio.file.Path resolveCacheRoot(String userSpecified) { + if (userSpecified != null && !userSpecified.isEmpty()) { + return java.nio.file.Paths.get(userSpecified); + } + String tmpDir = System.getProperty("java.io.tmpdir", "/tmp"); + return java.nio.file.Paths.get(tmpDir, "lance-hadoop-cache"); + } + + /** 把 URI 转为可作为文件系统目录名的安全字符串。 */ + private static String sanitize(String uri) { + return uri.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + /** 缓存目录是否已经就绪(存在 {@code _versions/} 子目录,Lance dataset 的标志)。 */ + private static boolean isCacheReady(java.nio.file.Path targetDir) { + if (!Files.isDirectory(targetDir)) return false; + java.nio.file.Path versionsDir = targetDir.resolve("_versions"); + return Files.isDirectory(versionsDir); + } + + /** + * 递归把 {@code srcDir} 下的所有文件同步到 {@code destDir}(本地目录)。 + * 保留相对目录结构。 + */ + private static void downloadDirectory(Path srcDir, java.nio.file.Path destDir, + Configuration conf) throws IOException { + FileSystem fs = srcDir.getFileSystem(conf); + LOG.info("Resolved Hadoop FileSystem for {}: uri={}, impl={}", + srcDir, fs.getUri(), fs.getClass().getName()); + FileStatus rootStatus = fs.getFileStatus(srcDir); + if (!rootStatus.isDirectory()) { + throw new IOException("Lance dataset path is not a directory: " + srcDir); + } + Files.createDirectories(destDir); + + long fileCount = 0L; + long byteCount = 0L; + int skippedSentinelFiles = 0; + RemoteIterator it = fs.listFiles(srcDir, true); + while (it.hasNext()) { + LocatedFileStatus st = it.next(); + if (st.isDirectory()) continue; + Path srcFile = st.getPath(); + String rel = relativize(srcDir, srcFile); + java.nio.file.Path dst = destDir.resolve(rel); + Files.createDirectories(dst.getParent()); + // 使用 copyToLocalFile:deleteSource=false, useRawLocalFileSystem=true 避免 checksum 校验 + try { + fs.copyToLocalFile(false, srcFile, new Path(dst.toUri()), true); + fileCount++; + byteCount += st.getLen(); + } catch (IOException e) { + // latest 哨兵 manifest(u64::MAX-1)只是 latest 版本的别名,损坏/不可读 + // 不影响 Lance 通过扫描 _versions 目录确定 latest,因此安全跳过。 + if (isLatestSentinelManifest(rel)) { + LOG.warn("Skipping unreadable latest-sentinel manifest {} (tbdsfs read error: {})", + srcFile, e.getMessage()); + skippedSentinelFiles++; + } else { + throw e; + } + } + } + LOG.info("Downloaded lance dataset from {} to {}: {} files, {} bytes (skipped {} unreadable latest-sentinel manifests)", + srcDir, destDir, fileCount, byteCount, skippedSentinelFiles); + } + + /** + * 判断相对路径 {@code rel} 是否指向 Lance 的 latest 哨兵 manifest + * (即 {@code _versions/18446744073709551614.manifest})。 + */ + private static boolean isLatestSentinelManifest(String rel) { + if (rel == null) return false; + int slash = rel.lastIndexOf('/'); + String name = slash >= 0 ? rel.substring(slash + 1) : rel; + return name.equals(LATEST_MANIFEST_SENTINEL + ".manifest"); + } + + /** + * 计算 {@code srcFile} 相对 {@code srcDir} 的路径字符串,永远使用 '/' 分隔符。 + */ + private static String relativize(Path srcDir, Path srcFile) { + String base = srcDir.toUri().getPath(); + String full = srcFile.toUri().getPath(); + if (!base.endsWith("/")) base = base + "/"; + if (full.startsWith(base)) { + return full.substring(base.length()); + } + // Fallback:直接取文件名 + return srcFile.getName(); + } +} diff --git a/src/main/java/org/apache/flink/connector/lance/util/LanceOpener.java b/src/main/java/org/apache/flink/connector/lance/util/LanceOpener.java index f43f86f..9f69560 100644 --- a/src/main/java/org/apache/flink/connector/lance/util/LanceOpener.java +++ b/src/main/java/org/apache/flink/connector/lance/util/LanceOpener.java @@ -58,23 +58,48 @@ private LanceOpener() {} /** Open a dataset honoring the time-travel options declared in {@code options}. */ public static Dataset open(String datasetPath, BufferAllocator allocator, LanceOptions options) { + return open(datasetPath, allocator, options, null); + } + + /** + * Open a dataset honoring the time-travel options declared in {@code options}. + * + * @param flinkConf Flink 运行时配置,用于把 {@code flink.hadoop.*}(如 + * {@code flink.hadoop.tbdsfs.meta})注入 Hadoop {@code Configuration}, + * 使 tbdsfs/hdfs 等 Hadoop 兼容 FileSystem 能正确初始化。可为 {@code null}。 + */ + public static Dataset open(String datasetPath, BufferAllocator allocator, LanceOptions options, + org.apache.flink.configuration.Configuration flinkConf) { + // 方案 B:对 tbdsfs/hdfs 等 Hadoop 兼容 scheme,先通过 Hadoop FileSystem 缓存到本地 + org.apache.hadoop.conf.Configuration hadoopConf = + LanceHadoopPathResolver.buildHadoopConfigurationFromFlink(flinkConf); + // 注入表 DDL WITH 里 hadoop.* 前缀的配置(如 tbdsfs.meta),优先级最高, + // 用于绕过集群部分节点 core-site.xml 缺配置的问题。 + if (options != null && options.getHadoopConfig() != null) { + for (java.util.Map.Entry e : options.getHadoopConfig().entrySet()) { + hadoopConf.set(e.getKey(), e.getValue()); + LOG.info("Injected Hadoop conf from Lance options: {} = {}", e.getKey(), e.getValue()); + } + } + String resolvedPath = LanceHadoopPathResolver.resolveForRead(datasetPath, hadoopConf, null); + Long explicitVersion = options.getReadVersion(); String asOf = options.getReadAsOfTimestamp(); if (explicitVersion != null) { - LOG.info("Opening Lance dataset {} at version {} (read.version)", datasetPath, explicitVersion); - return openAtVersion(datasetPath, allocator, explicitVersion); + LOG.info("Opening Lance dataset {} at version {} (read.version)", resolvedPath, explicitVersion); + return openAtVersion(resolvedPath, allocator, explicitVersion); } if (asOf != null && !asOf.isEmpty()) { - long resolved = resolveVersionForTimestamp(datasetPath, allocator, asOf); + long resolved = resolveVersionForTimestamp(resolvedPath, allocator, asOf); LOG.info("Opening Lance dataset {} at version {} (resolved from read.as-of-timestamp={})", - datasetPath, resolved, asOf); - return openAtVersion(datasetPath, allocator, resolved); + resolvedPath, resolved, asOf); + return openAtVersion(resolvedPath, allocator, resolved); } - LOG.debug("Opening Lance dataset {} at latest version (no time-travel options set)", datasetPath); - return Dataset.open(datasetPath, allocator); + LOG.debug("Opening Lance dataset {} at latest version (no time-travel options set)", resolvedPath); + return Dataset.open(resolvedPath, allocator); } private static Dataset openAtVersion(String datasetPath, BufferAllocator allocator, long version) { diff --git a/src/test/java/org/apache/flink/connector/lance/util/LanceHadoopPathResolverTest.java b/src/test/java/org/apache/flink/connector/lance/util/LanceHadoopPathResolverTest.java new file mode 100644 index 0000000..950ad1e --- /dev/null +++ b/src/test/java/org/apache/flink/connector/lance/util/LanceHadoopPathResolverTest.java @@ -0,0 +1,259 @@ +/* + * 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.flink.connector.lance.util; + +import org.apache.hadoop.conf.Configuration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link LanceHadoopPathResolver}. + * + *

Focus areas: + *

    + *
  • Native Lance schemes should be passed through unchanged.
  • + *
  • Local file URIs should be passed through unchanged.
  • + *
  • Hadoop-family schemes should trigger download to local cache and return {@code file://} path.
  • + *
  • Scheme detection utility should be case-insensitive and null-safe.
  • + *
+ */ +class LanceHadoopPathResolverTest { + + @TempDir + Path tempDir; + + @Test + void extractSchemeReturnsLowercaseScheme() { + assertThat(LanceHadoopPathResolver.extractScheme("S3://bucket/foo")).isEqualTo("s3"); + assertThat(LanceHadoopPathResolver.extractScheme("TBDSFS://svc/foo")).isEqualTo("tbdsfs"); + assertThat(LanceHadoopPathResolver.extractScheme("hdfs://nn/p")).isEqualTo("hdfs"); + assertThat(LanceHadoopPathResolver.extractScheme("file:///tmp/x")).isEqualTo("file"); + } + + @Test + void extractSchemeReturnsNullForBarePath() { + assertThat(LanceHadoopPathResolver.extractScheme("/tmp/foo/bar")).isNull(); + assertThat(LanceHadoopPathResolver.extractScheme("relative/path")).isNull(); + } + + @Test + void normalizeUriRestoresMissingSlashInAuthority() { + // 场景 1:Flink Path 反序列化把 "tbdsfs://internal/x" 变成 "tbdsfs:/internal/x", + // normalizeUri 应恢复 + assertThat(LanceHadoopPathResolver.normalizeUri("tbdsfs:/internal/lance_poc/db1")) + .isEqualTo("tbdsfs://internal/lance_poc/db1"); + // 场景 2:hdfs://ns1/path 也一样 + assertThat(LanceHadoopPathResolver.normalizeUri("hdfs:/ns1/path")) + .isEqualTo("hdfs://ns1/path"); + } + + @Test + void normalizeUriIsIdempotent() { + // 已经是双斜杠不变 + assertThat(LanceHadoopPathResolver.normalizeUri("tbdsfs://internal/x")) + .isEqualTo("tbdsfs://internal/x"); + // 空 authority 的 "scheme:///" 也不变 + assertThat(LanceHadoopPathResolver.normalizeUri("file:///tmp/x")) + .isEqualTo("file:///tmp/x"); + // 本地路径不变 + assertThat(LanceHadoopPathResolver.normalizeUri("/tmp/x")).isEqualTo("/tmp/x"); + assertThat(LanceHadoopPathResolver.normalizeUri(null)).isNull(); + } + + @Test + void isNativeSupportedSchemeRecognizesLanceSchemes() { + assertThat(LanceHadoopPathResolver.isNativeSupportedScheme("s3")).isTrue(); + assertThat(LanceHadoopPathResolver.isNativeSupportedScheme("S3")).isTrue(); + assertThat(LanceHadoopPathResolver.isNativeSupportedScheme("file")).isTrue(); + assertThat(LanceHadoopPathResolver.isNativeSupportedScheme("cos")).isTrue(); + assertThat(LanceHadoopPathResolver.isNativeSupportedScheme("tbdsfs")).isFalse(); + assertThat(LanceHadoopPathResolver.isNativeSupportedScheme("hdfs")).isFalse(); + assertThat(LanceHadoopPathResolver.isNativeSupportedScheme(null)).isFalse(); + } + + @Test + void resolveForReadPassesThroughNullAndEmpty() { + assertThat(LanceHadoopPathResolver.resolveForRead(null, null, null)).isNull(); + assertThat(LanceHadoopPathResolver.resolveForRead("", null, null)).isEmpty(); + } + + @Test + void resolveForReadPassesThroughLocalPath() { + String local = "/tmp/lance/dataset.lance"; + String resolved = LanceHadoopPathResolver.resolveForRead(local, null, null); + assertThat(resolved).isEqualTo(local); + } + + @Test + void resolveForReadPassesThroughFileUri() { + String fileUri = "file:///tmp/lance/dataset.lance"; + String resolved = LanceHadoopPathResolver.resolveForRead(fileUri, null, null); + assertThat(resolved).isEqualTo(fileUri); + } + + @Test + void resolveForReadPassesThroughS3Uri() { + String s3 = "s3://bucket/prefix/dataset.lance"; + String resolved = LanceHadoopPathResolver.resolveForRead(s3, null, null); + assertThat(resolved).isEqualTo(s3); + } + + @Test + void resolveForReadPassesThroughCosUri() { + String cos = "cos://bucket/prefix/dataset.lance"; + String resolved = LanceHadoopPathResolver.resolveForRead(cos, null, null); + assertThat(resolved).isEqualTo(cos); + } + + /** + * 使用自定义 scheme {@code mytestfs://}(lance 不原生支持),并通过 Hadoop + * 配置将该 scheme 重定向到 {@link org.apache.hadoop.fs.LocalFileSystem},从而完整 + * 走通 Hadoop FS 下载链路,且不依赖真实 HDFS。 + */ + @Test + void resolveForReadDownloadsFromHadoopFileSystem() throws IOException { + // 1. 构造一个“源” lance dataset 目录(用本地磁盘模拟) + Path srcDir = tempDir.resolve("src_dataset.lance"); + Path versionsDir = srcDir.resolve("_versions"); + Path dataDir = srcDir.resolve("data"); + Files.createDirectories(versionsDir); + Files.createDirectories(dataDir); + Files.write(versionsDir.resolve("1.manifest"), new byte[] {1, 2, 3, 4}); + Files.write(dataDir.resolve("part-0.lance"), "hello".getBytes()); + Files.write(srcDir.resolve("_latest.manifest"), new byte[] {9}); + + // 2. 配置:把自定义 scheme mytestfs 重定向到 Hadoop LocalFileSystem + Configuration conf = new Configuration(false); + conf.set("fs.mytestfs.impl", LocalFsWithMytestfsScheme.class.getName()); + + // 3. 使用 mytestfs:// 前缀访问源目录,触发 Hadoop FS 下载路径 + String srcUri = "mytestfs://" + srcDir.toAbsolutePath(); + Path cacheRoot = tempDir.resolve("cache"); + + String resolved = LanceHadoopPathResolver.resolveForRead( + srcUri, conf, cacheRoot.toAbsolutePath().toString()); + + // 4. 验证返回的是本地 file:// 路径,并且包含预期的文件 + assertThat(resolved).startsWith("file://"); + Path resolvedDir = Path.of(resolved.substring("file://".length())); + assertThat(Files.isDirectory(resolvedDir)).isTrue(); + assertThat(Files.isDirectory(resolvedDir.resolve("_versions"))).isTrue(); + assertThat(Files.isRegularFile(resolvedDir.resolve("_versions/1.manifest"))).isTrue(); + assertThat(Files.isRegularFile(resolvedDir.resolve("data/part-0.lance"))).isTrue(); + assertThat(Files.readString(resolvedDir.resolve("data/part-0.lance"))).isEqualTo("hello"); + } + + /** + * 二次 resolve 相同 URI 时应命中缓存,不重复下载。 + */ + @Test + void resolveForReadReusesCacheOnSecondCall() throws IOException { + Path srcDir = tempDir.resolve("cached_dataset.lance"); + Path versionsDir = srcDir.resolve("_versions"); + Files.createDirectories(versionsDir); + Files.write(versionsDir.resolve("1.manifest"), new byte[] {1}); + + Configuration conf = new Configuration(false); + conf.set("fs.mytestfs.impl", LocalFsWithMytestfsScheme.class.getName()); + + String srcUri = "mytestfs://" + srcDir.toAbsolutePath(); + Path cacheRoot = tempDir.resolve("cache2"); + + String first = LanceHadoopPathResolver.resolveForRead( + srcUri, conf, cacheRoot.toAbsolutePath().toString()); + // 修改缓存目录里的文件,模拟“已存在的缓存” + Path firstDir = Path.of(first.substring("file://".length())); + Path sentinel = firstDir.resolve("_versions/1.manifest"); + Files.write(sentinel, new byte[] {42}); + + String second = LanceHadoopPathResolver.resolveForRead( + srcUri, conf, cacheRoot.toAbsolutePath().toString()); + // 第二次 resolve 应返回同一个目录,且 sentinel 未被重新下载覆盖 + assertThat(second).isEqualTo(first); + assertThat(Files.readAllBytes(sentinel)).containsExactly(42); + } + + /** + * 将 Hadoop {@link org.apache.hadoop.fs.LocalFileSystem} 包装为 {@code mytestfs://} scheme, + * 测试专用。它仅重写 {@link #getScheme()} 以及将传入的 {@code mytestfs://} + * URI 重写为能被本地文件系统接受的 {@code file:/} 后转发到 LocalFileSystem。 + */ + public static final class LocalFsWithMytestfsScheme extends org.apache.hadoop.fs.LocalFileSystem { + @Override + public String getScheme() { + return "mytestfs"; + } + + @Override + public java.net.URI getUri() { + return java.net.URI.create("mytestfs:///"); + } + + @Override + public void initialize(java.net.URI name, Configuration conf) throws IOException { + super.initialize(java.net.URI.create("file:///"), conf); + } + + @Override + protected void checkPath(org.apache.hadoop.fs.Path path) { + // 接受任意 scheme(mytestfs 或 file) + } + + @Override + public org.apache.hadoop.fs.Path makeQualified(org.apache.hadoop.fs.Path path) { + return toLocal(path); + } + + @Override + public org.apache.hadoop.fs.FileStatus getFileStatus(org.apache.hadoop.fs.Path f) + throws IOException { + return super.getFileStatus(toLocal(f)); + } + + @Override + public org.apache.hadoop.fs.RemoteIterator listFiles( + org.apache.hadoop.fs.Path f, boolean recursive) throws IOException { + return super.listFiles(toLocal(f), recursive); + } + + @Override + public void copyToLocalFile(boolean delSrc, org.apache.hadoop.fs.Path src, + org.apache.hadoop.fs.Path dst, boolean useRawLocalFileSystem) + throws IOException { + super.copyToLocalFile(delSrc, toLocal(src), dst, useRawLocalFileSystem); + } + + private static org.apache.hadoop.fs.Path toLocal(org.apache.hadoop.fs.Path p) { + java.net.URI u = p.toUri(); + String path = u.getPath(); + if (u.getAuthority() != null && !u.getAuthority().isEmpty()) { + // mytestfs:// 会把绝对路径的首段解析为 authority + path = "/" + u.getAuthority() + (path == null ? "" : path); + } + return new org.apache.hadoop.fs.Path("file://" + path); + } + } +}