zy-kkk commented on code in PR #68453: URL: https://github.com/apache/doris/pull/68453#discussion_r4124882472
########## fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceSdkNamespace.java: ########## @@ -0,0 +1,233 @@ +// 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.doris.datasource.lance; + +import org.apache.doris.datasource.lance.storage.LanceStorageOptions; + +import com.google.common.collect.ImmutableSet; +import com.google.common.hash.Hasher; +import com.google.common.hash.Hashing; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.lance.namespace.LanceNamespace; +import org.lance.namespace.model.DescribeTableRequest; +import org.lance.namespace.model.DescribeTableResponse; +import org.lance.namespace.model.DescribeTableVersionRequest; +import org.lance.namespace.model.DescribeTableVersionResponse; +import org.lance.namespace.model.ListTableVersionsRequest; +import org.lance.namespace.model.ListTableVersionsResponse; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +/** + * The namespace the Lance SDK is handed to open one namespace-managed dataset: the catalog's + * namespace, with two things the SDK gets wrong on its own. + * + * <p>The SDK opens with the options it is handed plus whatever its own describe vends, spelled as + * the namespace spells them. Lance then adds the process environment for any option whose + * canonical key is missing, so a vended {@code endpoint} next to an {@code AWS_ENDPOINT} in the + * FE environment leaves the FE on whichever endpoint object_store folds last, while the BE, handed + * the canonical {@code aws_endpoint}, keeps the vended one. {@link #describeTable} therefore + * returns the vended options in the vocabulary Doris uses for everything else. + * + * <p>The SDK also caches the object store of a namespace-opened dataset in the catalog Session by + * the namespace's id and the table id alone, ignoring the options: a read overlapping another one + * that still holds a store for the same table reuses that store, whatever endpoint it was built + * for (lance-io {@code StorageOptionsAccessor::accessor_id}, {@code ObjectStoreRegistry::get_store}). + * {@link #namespaceId} therefore also identifies the options the store is built with, less + * credentials that carry an expiry, which the store refreshes from the namespace anyway. + * + * <p>A namespace Lance does not implement natively is called back through JNI, which reports an + * exception thrown by the callback only as "Java exception was thrown". The last one is kept for + * {@link #unwrapCallbackFailure}, so a missing version or branch is still reported as such. + * + * <p>One instance is created per open. The datasets checked out from it resolve versions through + * it, and the store the open builds refreshes credentials through it, also for later reads that + * share that store. + */ +final class LanceSdkNamespace implements LanceNamespace { + private static final Logger LOG = LogManager.getLogger(LanceSdkNamespace.class); + + /** What jni-rs reports for a Java exception a callback threw. */ + private static final String CALLBACK_FAILURE = "Java exception was thrown"; + + private static final String EXPIRES_AT_MILLIS = "expires_at_millis"; + + /** + * The credentials a store takes from its credential provider rather than fixing them when it + * is built: every spelling lance-io's {@code DynamicCredentials} conversions read for AWS, + * Azure and GCS, the OSS keys its dynamic OpenDAL store re-reads, and the refresh deadline. + * The provider refreshes them from the namespace only when they carry + * {@value #EXPIRES_AT_MILLIS}; see {@link #storeIdentity}. + */ + private static final Set<String> CREDENTIAL_OPTIONS = ImmutableSet.of( + "aws_access_key_id", "access_key_id", "aws_secret_access_key", "secret_access_key", + "aws_session_token", "aws_token", "aws_security_token", "session_token", "token", + "azure_storage_sas_token", "azure_storage_sas_key", "sas_token", "sas_key", + "azure_storage_token", "bearer_token", "azure_storage_account_key", "azure_storage_access_key", + "azure_storage_master_key", "access_key", "master_key", "account_key", + "google_storage_token", + "oss_access_key_id", "oss_secret_access_key", "oss_security_token", + EXPIRES_AT_MILLIS); + + /** Keys the store digest, so an id in a log cannot be checked against guessed credentials. */ + private static final byte[] IDENTITY_KEY = new byte[32]; + + static { + new SecureRandom().nextBytes(IDENTITY_KEY); + } + + private final LanceNamespace catalogNamespace; + private final Map<String, String> sdkStorageOptions; + private final AtomicReference<RuntimeException> callbackFailure = new AtomicReference<>(); + /** The thread that opens the dataset and issues the SDK's describe; every other call is a JNI callback. */ + private final Thread openingThread = Thread.currentThread(); + /** Set by the SDK's own describe while it opens the dataset. */ + private volatile String storeIdentity; + + /** + * @param sdkStorageOptions the options the SDK is handed in its read options, which it opens + * with under what its describe vends + */ + LanceSdkNamespace(LanceNamespace catalogNamespace, Map<String, String> sdkStorageOptions) { + this.catalogNamespace = catalogNamespace; + this.sdkStorageOptions = sdkStorageOptions; + } + + @Override + public void initialize(Map<String, String> configProperties, BufferAllocator allocator) { + throw new UnsupportedOperationException("A Lance SDK namespace wraps an initialized catalog namespace"); + } + + /** + * Read by the SDK once, when it opens the dataset: Lance 12 describes the table in + * {@code OpenDatasetBuilder.buildFromNamespaceClient} first and reads the id when the JNI + * wraps this namespace. It keys the store cache, through the credential provider the SDK + * builds from this namespace. + */ + @Override + public String namespaceId() { + String identity = storeIdentity; + if (identity == null) { + throw new IllegalStateException("The Lance SDK read the namespace id before describing the table"); + } + return "DorisSdkNamespace[" + catalogNamespace.namespaceId() + ", store=" + identity + "]"; + } + + /** + * The catalog namespace's describe, with the vended options normalized. The first call is the + * SDK's own describe while it opens the dataset, whose options the store is built with; later + * ones refresh credentials. + */ + @Override + public DescribeTableResponse describeTable(DescribeTableRequest request) { + return record(() -> { + DescribeTableResponse response = catalogNamespace.describeTable(request); + Map<String, String> vended = LanceStorageOptions.normalizeVendedStorageOptions( + response.getLocation(), response.getStorageOptions()); + // Left null when nothing was vended: a credential refresh then keeps the options it has. + if (response.getStorageOptions() != null) { + response.setStorageOptions(vended); + } + if (storeIdentity == null) { + Map<String, String> opened = new HashMap<>(sdkStorageOptions); + opened.putAll(vended); + storeIdentity = storeIdentity(opened); + } + return response; + }); + } + + @Override + public ListTableVersionsResponse listTableVersions(ListTableVersionsRequest request) { + return record(() -> catalogNamespace.listTableVersions(request)); + } + + @Override + public DescribeTableVersionResponse describeTableVersion(DescribeTableVersionRequest request) { + return record(() -> catalogNamespace.describeTableVersion(request)); + } + + /** + * The exception a failed SDK call reported as a failed callback, in place of that report, or + * {@code sdkError} itself. Each kept exception is handed out once. + */ + Exception unwrapCallbackFailure(Exception sdkError) { + if (!isCallbackFailure(sdkError)) { + return sdkError; + } + RuntimeException failure = callbackFailure.getAndSet(null); + if (failure == null) { + return sdkError; + } + failure.addSuppressed(sdkError); + return failure; + } + + private static boolean isCallbackFailure(Throwable error) { + return ExceptionUtils.getThrowableList(error).stream() + .anyMatch(cause -> cause.getMessage() != null && cause.getMessage().contains(CALLBACK_FAILURE)); + } + + private <T> T record(Supplier<T> call) { + try { + return call.get(); + } catch (RuntimeException e) { + callbackFailure.set(e); + if (Thread.currentThread() != openingThread) { + // The JNI leaves the exception pending on a thread it attached only for this call, + // and the JVM reports it as uncaught when that thread detaches. It is not: it + // reaches the read through unwrapCallbackFailure. + Thread.currentThread().setUncaughtExceptionHandler( + (thread, error) -> LOG.debug("Lance namespace callback failed", error)); + } + throw e; + } + } + + /** + * A digest of every option that fixes where and how a store connects. The options can name + * endpoints and account names, so only the digest reaches the id, which Lance logs. + * + * <p>Credentials that carry an expiry are left out: the store refreshes them from the namespace + * before they expire, and a namespace that vends new ones on every describe would otherwise + * leave one registry entry behind per read. Without an expiry the store never refreshes, and + * keeps the credentials it was built with for as long as any read holds it, so they count, as + * they do for a store Lance opens without a namespace. + */ + static String storeIdentity(Map<String, String> options) { + boolean refreshed = options.containsKey(EXPIRES_AT_MILLIS); Review Comment: Confirmed: with `use_opendal` set, lance-io's S3 constructor (the Azure and GCS ones too) builds a static OpenDAL operator from the initial options and never consults the credential accessor, so an expiry does not make those credentials refresh. Fixed in 8e0eda26dae. Credentials now stay out of the store identity only when the options carry `expires_at_millis` and `use_opendal` is not truthy (the values lance-core's `str_is_truthy` accepts). An OpenDAL store's credentials count, as a non-expiring key's do. OSS ignores the flag and always builds a refreshing dynamic OpenDAL store, so counting its credentials under `use_opendal` only splits stores that could have been shared. Two more cases in the same identity came up while re-reviewing it. The table location now counts too, without its query string: a namespace may vend credentials that only cover the table's own prefix (Lance's own AWS, GCS and Azure vendors do), so a table recreated in another directory of the same bucket must not reuse a store signed for the old prefix. And an `expires_at_millis` counts only if it parses as an unsigned 64-bit integer, the only form lance-io reads; with anything else the store never refreshes, so the credentials stay in the identity. Test: `LanceManagedS3StoreTest.testOverlappingOpenDalReadsKeepTheirOwnCredentials` repeats the overlapping key rotation with `use_opendal=true` and an expiry an hour away. Before the change, Q2 failed with 403 through Q1's store. `LanceSdkNamespaceTest` covers the rules without JNI (`testStoreIdentityCountsCredentialsOfAnOpenDalStore`, `testStoreIdentityCountsTheLocation`, `testStoreIdentityCountsCredentialsWithAnExpiryLanceCannotRead`). ########## fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceCatalogClient.java: ########## @@ -235,68 +252,508 @@ public LanceTableMetadata loadBasicTableMetadata(String dbName, String tableName } public Schema loadTableSchema(String dbName, String tableName) { - return readTableSnapshot(dbName, tableName, Optional.empty(), + return readTableSnapshot(dbName, tableName, LanceRefSelector.latest(), (dataset, access, metrics) -> metrics.measure(Stage.SCHEMA, dataset::getSchema)); } public LanceTableMetadata loadTableMetadata(String dbName, String tableName, Optional<TableSnapshot> tableSnapshot) { - return loadQueryMetadata(dbName, tableName, tableSnapshot, LanceMetadataLoader.MetadataScope.WITH_INDEXES); + return loadTableMetadata(dbName, tableName, LanceRefSelector.snapshot(tableSnapshot)); + } + + public LanceTableMetadata loadTableMetadata(String dbName, String tableName, LanceRefSelector selector) { + return loadQueryMetadata(dbName, tableName, selector, LanceMetadataLoader.MetadataScope.WITH_INDEXES); } private LanceTableMetadata loadQueryMetadata(String dbName, String tableName, Optional<TableSnapshot> tableSnapshot, LanceMetadataLoader.MetadataScope mode) { - return readTableSnapshot(dbName, tableName, tableSnapshot, + return loadQueryMetadata(dbName, tableName, LanceRefSelector.snapshot(tableSnapshot), mode); + } + + private LanceTableMetadata loadQueryMetadata(String dbName, String tableName, + LanceRefSelector selector, LanceMetadataLoader.MetadataScope mode) { + return readTableSnapshot(dbName, tableName, selector, (dataset, access, metrics) -> LanceMetadataLoader.read(dataset, access, mode, metrics)); } - /** Pins one resource generation, resolved table access, and the Dataset version for the whole read. */ - private <T> T readTableSnapshot(String dbName, String tableName, Optional<TableSnapshot> tableSnapshot, + /** + * Pins one resource generation, resolved table access, and the Dataset version for the whole read. + * + * <p>The latest version of the main chain is opened once and every other selector is a + * checkout from that handle, so the SDK resolves the ref with the same commit handler + * (the namespace's, for a managed table). A tag is resolved first to the chain and version it + * points at, so a tag created on a branch selects that branch. The two shortcuts that skip the + * latest open are an explicit version on the main chain, and the latest version of a managed + * table. For a managed table, "latest" is always the newest version the namespace records, + * never the newest manifest in storage. + */ + private <T> T readTableSnapshot(String dbName, String tableName, LanceRefSelector selector, SnapshotReader<T> reader) { - LanceTableAccess tableAccess = null; + ReadState state = new ReadState(selector, dbName + "." + tableName); LanceMetadataMetrics metrics = LanceMetadataMetrics.startMetadataRead(); try { T result; try (BufferAllocator allocator = namespaceAllocator.newChildAllocator( "lance-metadata-read", 0, namespaceAllocator.getLimit())) { - tableAccess = metrics.measure(Stage.TABLE_ACCESS, + state.access = metrics.measure(Stage.TABLE_ACCESS, () -> namespaceClient.resolveTableAccess(dbName, tableName)); - OptionalLong version = OptionalLong.empty(); - if (tableSnapshot.isPresent()) { - TableSnapshot snapshot = tableSnapshot.get(); - if (snapshot.getType() == TableSnapshot.VersionType.VERSION) { - version = OptionalLong.of(LanceSnapshotResolver.parseVersion(snapshot.getValue())); - } else { - long timestamp = TimeUtils.timeStringToLong(snapshot.getValue(), TimeUtils.getTimeZone()); - if (timestamp < 0) { - throw new IllegalArgumentException( - "Cannot parse Lance FOR TIME AS OF value '" + snapshot.getValue() + "'"); - } - try (Dataset latest = openDataset(allocator, tableAccess, OptionalLong.empty(), metrics)) { - version = OptionalLong.of(metrics.measure(Stage.VERSION_RESOLVE, - () -> LanceSnapshotResolver.getVersionAtOrBefore(latest, timestamp))); - } + OptionalLong direct = directMainVersion(state, metrics); + if (direct.isPresent() || isLatestMain(selector)) { + state.version = direct; + try (Dataset dataset = openDataset(allocator, state, direct, metrics)) { + result = reader.read(dataset, state.access, metrics); + } + } else { + OptionalLong mainVersion = state.access.isManagedVersioning() + ? OptionalLong.of(recordedLatestVersion(state, Optional.empty(), metrics)) + : OptionalLong.empty(); + try (Dataset main = openDataset(allocator, state, mainVersion, metrics)) { + result = readFromLatest(main, state, reader, metrics); } - } - try (Dataset dataset = openDataset(allocator, tableAccess, version, metrics)) { - result = reader.read(dataset, tableAccess, metrics); } } metrics.succeeded(); return result; - } catch (Exception e) { - throw LanceErrorMessages.failure("Failed to load Lance table metadata for " + dbName + "." + tableName, e, - tableAccess == null ? null : tableAccess.getDatasetUri(), - tableAccess == null ? namespaceStorageOptions : tableAccess.getStorageOptions(), catalogSecrets); + } catch (LanceUserFacingException e) { + throw new RuntimeException(e.getMessage(), e); + } catch (Exception sdkError) { + Exception e = unwrapCallbackFailure(state, sdkError); + LanceTableAccess access = state.access; + String uri = access == null ? null : access.getDatasetUri(); + Map<String, String> options = access == null ? namespaceStorageOptions : access.getStorageOptions(); + String what = state.displayName(); + if (state.branch.isPresent() && !state.branchExists && isBranchNotFound(e, state.branch.get())) { + throw new RuntimeException("Lance branch '" + state.branch.get() + "' of " + state.tableName + + state.selector.getTag().map(tag -> " (tag '" + tag + "')").orElse("") + + " was not found" + (isNamespaceMiss(e, "table branch not found") ? " in the namespace" : ""), + sanitizedCause(e, uri, options)); + } + if (state.version.isPresent() && isVersionNotFound(e)) { + throw new RuntimeException("Lance version " + state.version.getAsLong() + " of " + what + + state.selector.getTag().map(tag -> " (tag '" + tag + "')").orElse("") + + " was not found" + (isNamespaceMiss(e, "table version not found") ? " in the namespace" : ""), + sanitizedCause(e, uri, options)); + } + String hint = access != null && access.isManagedVersioning() && isAccessDenied(e) + ? " (reading a namespace-managed Lance table may need write access to finalize a staged manifest)" + : ""; + throw LanceErrorMessages.failure("Failed to load Lance table metadata for " + what + hint, e, uri, options, + catalogSecrets); } finally { metrics.close(); } } - private Dataset openDataset(BufferAllocator allocator, LanceTableAccess access, OptionalLong version, + /** What a read has resolved so far; the catch block reports errors against it. */ + private static final class ReadState { + private final LanceRefSelector selector; + private final String tableName; + private LanceTableAccess access; + /** The namespace the SDK opened a managed table through, which keeps its callbacks' failures. */ + private LanceSdkNamespace sdkNamespace; + private Optional<String> branch; + /** + * Set once the branch is known to exist: the namespace recorded versions for it, or its + * latest version was checked out. Later failures are not reported as a missing branch. + */ + private boolean branchExists; + private OptionalLong version = OptionalLong.empty(); + /** The namespace's version list per chain ("" is main), fetched at most once per read. */ + private final Map<String, List<TableVersion>> namespaceVersions = new HashMap<>(); + + private ReadState(LanceRefSelector selector, String tableName) { + this.selector = selector; + this.tableName = tableName; + this.branch = selector.getBranch(); + } + + private String displayName() { + return tableName + branch.map(name -> "@" + name).orElse(""); + } + } + + private static boolean isLatestMain(LanceRefSelector selector) { + return !selector.getTag().isPresent() && !selector.getBranch().isPresent() + && !selector.getSnapshot().isPresent(); + } + + /** + * The main-chain version a selector names without looking at the latest manifest: an explicit + * version, or the latest version of a managed table, which the namespace records. + */ + private OptionalLong directMainVersion(ReadState state, LanceMetadataMetrics metrics) { + LanceRefSelector selector = state.selector; + if (selector.getTag().isPresent() || selector.getBranch().isPresent()) { + return OptionalLong.empty(); + } + if (!selector.getSnapshot().isPresent()) { + return state.access.isManagedVersioning() + ? OptionalLong.of(recordedLatestVersion(state, Optional.empty(), metrics)) + : OptionalLong.empty(); + } + TableSnapshot snapshot = selector.getSnapshot().get(); + return snapshot.getType() == TableSnapshot.VersionType.VERSION + ? OptionalLong.of(LanceSnapshotResolver.parseVersion(snapshot.getValue())) + : OptionalLong.empty(); + } + + /** Resolves the selector against the open latest main chain and reads the selected snapshot. */ + private <T> T readFromLatest(Dataset main, ReadState state, SnapshotReader<T> reader, LanceMetadataMetrics metrics) + throws Exception { + LanceRefSelector selector = state.selector; + if (selector.getTag().isPresent()) { + // Only this tag's file is read, however many tags the table has. The SDK checks the tag + // out on the branch of the version it points at; for a managed table that is an + // explicit version the namespace resolves, never a storage fallback. + String tag = selector.getTag().get(); + state.version = OptionalLong.of(metrics.measure(Stage.VERSION_RESOLVE, () -> tagVersion(main, tag, state))); + try (Dataset target = checkout(main, Ref.ofTag(tag), metrics)) { + state.branch = branchOf(target.uri(), state.access.getDatasetUri()); + return reader.read(target, accessOf(target, state), metrics); + } + } + if (state.branch.isPresent()) { + String branch = state.branch.get(); + // Check out the branch's latest version first even when a version is already known, so + // a missing branch and a missing version inside an existing branch are told apart. + Ref branchHead = Ref.ofBranch(branch); + if (state.access.isManagedVersioning()) { + branchHead = Ref.ofBranch(branch, recordedLatestVersion(state, Optional.of(branch), metrics)); + state.branchExists = true; + } + try (Dataset latest = checkout(main, branchHead, metrics)) { + state.branchExists = true; + LanceTableAccess branchAccess = accessOf(latest, state); + if (!state.version.isPresent() && selector.getSnapshot().isPresent()) { + state.version = resolveSnapshotVersion(latest, branchAccess, selector.getSnapshot().get(), state, + metrics); + } + if (!state.version.isPresent()) { + return reader.read(latest, branchAccess, metrics); + } + try (Dataset dataset = checkout(latest, Ref.ofBranch(branch, state.version.getAsLong()), metrics)) { + return reader.read(dataset, branchAccess, metrics); + } + } + } + // FOR TIME AS OF on the main chain, resolved from manifest commit times. + state.version = resolveSnapshotVersion(main, state.access, selector.getSnapshot().get(), state, metrics); + try (Dataset dataset = checkout(main, Ref.ofMain(state.version.getAsLong()), metrics)) { + return reader.read(dataset, state.access, metrics); + } + } + + private static long tagVersion(Dataset main, String tag, ReadState state) { + try { + return main.tags().getVersion(tag); + } catch (RuntimeException e) { + String rootMessage = ExceptionUtils.getRootCauseMessage(e); + if (rootMessage != null && rootMessage.contains("tag " + tag + " does not exist")) { + throw new LanceUserFacingException("Lance tag '" + tag + "' of " + state.tableName + " was not found"); + } + throw e; + } + } + + /** + * The branch a dataset checked out from the table root is on, from its root directory: the + * table root for main, {@code <root>/tree/<branch>} otherwise. Lance inserts the branch path + * before a URI's query string, so the query is compared apart. A URI that is neither is an + * error rather than main, which would hand the BE the wrong chain. + */ + static Optional<String> branchOf(String checkedOutUri, String tableUri) { + String root = StringUtils.removeEnd(StringUtils.substringBefore(tableUri, "?"), "/"); + String uri = StringUtils.removeEnd(StringUtils.substringBefore(checkedOutUri, "?"), "/"); + if (uri.equals(root)) { + return Optional.empty(); + } + String branchRoot = root + "/tree/"; + if (!uri.startsWith(branchRoot) || uri.length() == branchRoot.length()) { + // The URIs may carry credentials in their query, so they stay out of the message. + throw new IllegalStateException("Cannot tell which branch a Lance tag was checked out on"); + } + return Optional.of(uri.substring(branchRoot.length())); + } + + /** + * The access for a dataset checked out from the table: the main chain keeps the table access, + * and a branch takes the directory the SDK checked out, which is what the BE opens by URI. + */ + private static LanceTableAccess accessOf(Dataset dataset, ReadState state) { + return state.branch.isPresent() ? state.access.onBranch(state.branch.get(), dataset.uri()) : state.access; + } + + /** A selector error whose message is user-facing as is, such as a tag that does not exist. */ + private static final class LanceUserFacingException extends RuntimeException { + private LanceUserFacingException(String message) { + super(message); + } + } + + /** + * The newest version the namespace records for a managed chain. Doris asks for it itself: + * opening "latest" through the SDK falls back to the newest manifest in storage when the + * namespace records none, which would expose a version the namespace never published. + */ + private long recordedLatestVersion(ReadState state, Optional<String> branch, LanceMetadataMetrics metrics) { + // A read that already listed the chain's versions reuses that list. + List<TableVersion> listed = state.namespaceVersions.get(branch.orElse("")); + OptionalLong latest = listed != null + ? listed.stream().map(TableVersion::getVersion).filter(Objects::nonNull) + .mapToLong(Long::longValue).max() + : metrics.measure(Stage.VERSION_RESOLVE, + () -> namespaceClient.latestManagedVersion(state.access, branch)); + if (!latest.isPresent()) { + throw new LanceUserFacingException("Lance namespace lists no versions for " + state.tableName + + branch.map(name -> "@" + name).orElse("")); + } + return latest.getAsLong(); + } + + /** + * The failure behind {@code sdkError}. For a managed table the SDK resolves versions through + * {@link LanceSdkNamespace} by JNI callback, and reports a namespace error there without its + * type or message; the namespace kept it. + */ + private static Exception unwrapCallbackFailure(ReadState state, Exception sdkError) { + return state.sdkNamespace == null ? sdkError : state.sdkNamespace.unwrapCallbackFailure(sdkError); + } + + private RuntimeException sanitizedCause(Throwable error, String uri, Map<String, String> options) { + return new RuntimeException(LanceErrorMessages.sanitize(error, uri, options, catalogSecrets)); + } + + /** + * Checks out a ref of an already open dataset. The SDK resolves the ref itself, from the + * dataset directory or, for a namespace-managed dataset, with its own namespace client. + */ + private static Dataset checkout(Dataset dataset, Ref ref, LanceMetadataMetrics metrics) { + return metrics.measure(Stage.VERSION_RESOLVE, () -> dataset.checkout(ref)); + } + + /** + * Resolves a {@code FOR VERSION AS OF} / {@code FOR TIME AS OF} snapshot against the chain + * {@code latest} is checked out on: the main chain, or a branch when {@code access} is a + * branch access. + */ + private OptionalLong resolveSnapshotVersion(Dataset latest, LanceTableAccess access, TableSnapshot snapshot, + ReadState state, LanceMetadataMetrics metrics) { + if (snapshot.getType() == TableSnapshot.VersionType.VERSION) { + return OptionalLong.of(LanceSnapshotResolver.parseVersion(snapshot.getValue())); + } + long timestamp = parseTimeTravelTimestamp(snapshot.getValue()); + try { + return OptionalLong.of(resolveVersionAtOrBefore(latest, access, timestamp, snapshot.getValue(), state, + metrics)); + } catch (LanceSnapshotResolver.NoVersionAtOrBeforeException e) { + if (!access.getBranch().isPresent()) { + throw e; + } + // A branch's chain starts at the version it was created from and carries its own + // commit times, so an earlier timestamp has nothing to select on the branch. + throw new LanceUserFacingException("Lance branch '" + access.getBranch().get() + "' of " + + state.tableName + " has no version at or before '" + snapshot.getValue() + + "'; a branch only holds the versions from its creation on"); + } + } + + /** + * Whether a failed branch checkout means the branch does not exist. The SDK reports + * "branch <name> does not exist", a namespace "Table branch not found", or a missing manifest + * under the branch directory when nothing was ever committed there. + */ + private static boolean isBranchNotFound(Throwable throwable, String branch) { + if (ExceptionUtils.indexOfType(throwable, TableBranchNotFoundException.class) >= 0) { + return true; + } + String rootMessage = ExceptionUtils.getRootCauseMessage(throwable); + if (rootMessage == null) { + return false; + } + String lower = rootMessage.toLowerCase(Locale.ROOT); + String name = branch.toLowerCase(Locale.ROOT); + return lower.contains("table branch not found") + || lower.contains("branch " + name + " does not exist") + || (lower.contains("not found") && lower.contains("tree/" + name + "/")); + } + + /** + * Whether a not-found came from the namespace rather than storage. The SDK surfaces a + * namespace error by its display text ("Table version not found: ..."), and the Java client + * by its exception type. + */ + private static boolean isNamespaceMiss(Throwable throwable, String namespaceText) { + if (ExceptionUtils.indexOfType(throwable, TableVersionNotFoundException.class) >= 0 + || ExceptionUtils.indexOfType(throwable, TableBranchNotFoundException.class) >= 0) { + return true; + } + String rootMessage = ExceptionUtils.getRootCauseMessage(throwable); + return rootMessage != null && rootMessage.toLowerCase(Locale.ROOT).contains(namespaceText); + } + + /** An HTTP 403 as the object stores report it, or an explicit access-denied error. */ + private static final Pattern ACCESS_DENIED = Pattern.compile( + "accessdenied|access denied|permission denied|forbidden|(status|http|code)\\W{0,3}403\\b"); + + private static boolean isAccessDenied(Throwable throwable) { + String rootMessage = ExceptionUtils.getRootCauseMessage(throwable); + return rootMessage != null && ACCESS_DENIED.matcher(rootMessage.toLowerCase(Locale.ROOT)).find(); + } + + /** + * Every version the namespace records for the chain {@code access} addresses, listed once per + * read. The whole list is needed: the storage fallback filters by it, and neither the order a + * namespace returns nor monotonic commit times can be relied on to stop early. + */ + private List<TableVersion> namespaceVersions(ReadState state, LanceTableAccess access, LanceMetadataMetrics metrics) { + return state.namespaceVersions.computeIfAbsent(access.getBranch().orElse(""), chain -> { + List<TableVersion> versions = metrics.measure(Stage.VERSION_RESOLVE, + () -> namespaceClient.listManagedVersions(access)); + if (versions.isEmpty()) { + throw new LanceUserFacingException("Lance namespace lists no versions for " + + state.tableName + (chain.isEmpty() ? "" : "@" + chain)); + } + return versions; + }); + } + + /** + * Resolves {@code FOR TIME AS OF} to a version on the chain {@code latest} is checked out on, + * from the commit times the manifests record, over the history {@link LanceSnapshotResolver} + * describes. A managed table only selects among the versions its namespace records; one + * missing from the storage listing because its manifest is still staged is checked out, which + * finalizes it and yields its commit time. + */ + private long resolveVersionAtOrBefore(Dataset latest, LanceTableAccess access, long timestamp, + String requestedText, ReadState state, LanceMetadataMetrics metrics) { + NavigableSet<Long> recorded = access.isManagedVersioning() + ? namespaceVersions(state, access, metrics).stream().map(TableVersion::getVersion) + .filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new)) + : null; + long version = metrics.measure(Stage.VERSION_RESOLVE, () -> { + try { + return LanceSnapshotResolver.versionAtOrBefore(latest.listVersions(), recorded, + id -> recordedVersion(latest, access, id, state), timestamp, requestedText); + } catch (LanceSnapshotResolver.HistoryRemovedException e) { + throw historyRemoved(e.getVersion(), requestedText, state); + } + }); + LOG.debug("Resolved Lance FOR TIME AS OF '{}' to version {} from manifest commit times", requestedText, + version); + return version; + } + + /** + * A namespace-recorded version checked out through the namespace, or null if it is gone. A + * still-staged manifest is finalized by the checkout. + */ + private static Version recordedVersion(Dataset latest, LanceTableAccess access, long version, + ReadState state) { + Ref ref = access.getBranch().map(name -> Ref.ofBranch(name, version)).orElseGet(() -> Ref.ofMain(version)); + try (Dataset recorded = latest.checkout(ref)) { + return recorded.getVersion(); + } catch (Exception e) { + // Also the IOException the JNI raises for a missing manifest. + Exception failure = unwrapCallbackFailure(state, e); + if (isVersionNotFound(failure)) { + return null; + } + // The namespace's own exception if it kept one; otherwise the SDK's, which may be the + // checked IOException the JNI throws undeclared. + if (failure instanceof RuntimeException) { + throw (RuntimeException) failure; + } + throw e; + } + } + + private static LanceUserFacingException historyRemoved(long version, String requestedText, ReadState state) { + return new LanceUserFacingException("Lance cannot resolve FOR TIME AS OF '" + requestedText + "' on " + + state.displayName() + ": version " + version + ", which may hold the state at that time," + + " no longer exists"); + } + + /** + * Parses a {@code FOR TIME AS OF} value in the session time zone. Second and millisecond + * precision are accepted; commit times are compared at millisecond precision, the precision a + * namespace reports them in, so a timestamp in the millisecond a commit lands in selects it. + */ + private static long parseTimeTravelTimestamp(String value) { + long timestamp = TimeUtils.timeStringToLong(value, TimeUtils.getTimeZone()); + if (timestamp < 0) { + timestamp = TimeUtils.msTimeStringToLong(value, TimeUtils.getTimeZone()); + } + if (timestamp < 0) { + throw new IllegalArgumentException("Cannot parse Lance FOR TIME AS OF value '" + value + + "', expected 'yyyy-MM-dd HH:mm:ss' or 'yyyy-MM-dd HH:mm:ss.SSS'"); + } + return timestamp; + } + + /** + * Whether a failed open of an explicitly requested version means that version does not exist. + * A namespace reports it through {@link TableVersionNotFoundException}. The storage reader + * reports it as a missing manifest under {@code _versions/} or as Lance's own version-not-found + * error; a missing dataset or an unreachable store fails differently and keeps its message. + */ + private static boolean isVersionNotFound(Throwable throwable) { + if (ExceptionUtils.indexOfType(throwable, TableVersionNotFoundException.class) >= 0) { + return true; + } + String rootMessage = ExceptionUtils.getRootCauseMessage(throwable); + if (rootMessage == null) { + return false; + } + String lower = rootMessage.toLowerCase(Locale.ROOT); + return lower.contains("version not found") + || (lower.contains("not found") && lower.contains("_versions/")); + } + + /** + * Opens the main chain of the table {@code state} resolved. For a managed table the SDK + * describes the table again and opens the location and storage options that describe returns, + * so {@code state.access} is replaced by the access for what it opened: the BE reads with the + * access this read ends up with, and must open what the FE planned. If the SDK did not open + * with exactly that access's options, the dataset is opened once more with them. A namespace + * that returns a relative location cannot be read in this mode. + */ + private Dataset openDataset(BufferAllocator allocator, ReadState state, OptionalLong version, + LanceMetadataMetrics metrics) { + if (state.access.isManagedVersioning()) { + LanceTableAccess access = state.access; + for (int attempt = 0; ; attempt++) { + ReadOptions readOptions = LanceReadOptions.forSharedSession(access.getStorageOptions(), version, + session); + LanceTableAccess requested = access; + LanceSdkNamespace sdkNamespace = namespaceClient.sdkNamespace(requested); + state.sdkNamespace = sdkNamespace; + Dataset dataset = metrics.measure(Stage.DATASET_OPEN, () -> namespaceClient.openManagedDataset( Review Comment: Confirmed: `resolve_version_location` opens a recorded path that ends in `.manifest` as is (it only HEADs it), while the BE resolves the version number under `_versions/`. Fixed in 8e0eda26dae. Every manifest location Lance takes from the namespace reaches it through the SDK wrapper from the previous round: `describeTableVersion` (`get`) and `listTableVersions` (`get_latest_version`). Both now reject an entry whose path ends in `.manifest` but is not the version's canonical path on the chain the BE opens: `<table root>[/tree/<branch>]/_versions/<u64::MAX - v>.manifest`, or the V1 `<v>.manifest`. The table root is the object-store path of the location the SDK's own describe returned, derived as lance-io derives it: the URL path after the bucket or container, percent-decoded, a scheme-less path as is, and empty for a table at the bucket root. A staged path passes, because Lance copies it to the canonical path before reading it. The read fails with the recorded and the canonical path in the message. The PR description and the docs listed this case as undetectable; they now say the read fails. Test: `LanceManagedVersioningTest.testFinalizedManifestOutsideItsCanonicalPathFailsTheRead` records version 3 at a finalized copy of its manifest in another directory. The latest read fails, and version 2 still reads. Before the change, the read succeeded from the copy. `LanceSdkNamespaceTest.testFinalizedManifestsMustBeAtTheirCanonicalPath` covers V1 and V2 naming, branches, staged paths, other directories, list entries, a bucket-root table and unencoded special characters in the location; `testObjectStorePathFollowsLance` pins the path derivation. ########## fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceCatalogClient.java: ########## @@ -235,68 +252,508 @@ public LanceTableMetadata loadBasicTableMetadata(String dbName, String tableName } public Schema loadTableSchema(String dbName, String tableName) { - return readTableSnapshot(dbName, tableName, Optional.empty(), + return readTableSnapshot(dbName, tableName, LanceRefSelector.latest(), (dataset, access, metrics) -> metrics.measure(Stage.SCHEMA, dataset::getSchema)); } public LanceTableMetadata loadTableMetadata(String dbName, String tableName, Optional<TableSnapshot> tableSnapshot) { - return loadQueryMetadata(dbName, tableName, tableSnapshot, LanceMetadataLoader.MetadataScope.WITH_INDEXES); + return loadTableMetadata(dbName, tableName, LanceRefSelector.snapshot(tableSnapshot)); + } + + public LanceTableMetadata loadTableMetadata(String dbName, String tableName, LanceRefSelector selector) { + return loadQueryMetadata(dbName, tableName, selector, LanceMetadataLoader.MetadataScope.WITH_INDEXES); } private LanceTableMetadata loadQueryMetadata(String dbName, String tableName, Optional<TableSnapshot> tableSnapshot, LanceMetadataLoader.MetadataScope mode) { - return readTableSnapshot(dbName, tableName, tableSnapshot, + return loadQueryMetadata(dbName, tableName, LanceRefSelector.snapshot(tableSnapshot), mode); + } + + private LanceTableMetadata loadQueryMetadata(String dbName, String tableName, + LanceRefSelector selector, LanceMetadataLoader.MetadataScope mode) { + return readTableSnapshot(dbName, tableName, selector, (dataset, access, metrics) -> LanceMetadataLoader.read(dataset, access, mode, metrics)); } - /** Pins one resource generation, resolved table access, and the Dataset version for the whole read. */ - private <T> T readTableSnapshot(String dbName, String tableName, Optional<TableSnapshot> tableSnapshot, + /** + * Pins one resource generation, resolved table access, and the Dataset version for the whole read. + * + * <p>The latest version of the main chain is opened once and every other selector is a + * checkout from that handle, so the SDK resolves the ref with the same commit handler + * (the namespace's, for a managed table). A tag is resolved first to the chain and version it + * points at, so a tag created on a branch selects that branch. The two shortcuts that skip the + * latest open are an explicit version on the main chain, and the latest version of a managed + * table. For a managed table, "latest" is always the newest version the namespace records, + * never the newest manifest in storage. + */ + private <T> T readTableSnapshot(String dbName, String tableName, LanceRefSelector selector, SnapshotReader<T> reader) { - LanceTableAccess tableAccess = null; + ReadState state = new ReadState(selector, dbName + "." + tableName); LanceMetadataMetrics metrics = LanceMetadataMetrics.startMetadataRead(); try { T result; try (BufferAllocator allocator = namespaceAllocator.newChildAllocator( "lance-metadata-read", 0, namespaceAllocator.getLimit())) { - tableAccess = metrics.measure(Stage.TABLE_ACCESS, + state.access = metrics.measure(Stage.TABLE_ACCESS, () -> namespaceClient.resolveTableAccess(dbName, tableName)); - OptionalLong version = OptionalLong.empty(); - if (tableSnapshot.isPresent()) { - TableSnapshot snapshot = tableSnapshot.get(); - if (snapshot.getType() == TableSnapshot.VersionType.VERSION) { - version = OptionalLong.of(LanceSnapshotResolver.parseVersion(snapshot.getValue())); - } else { - long timestamp = TimeUtils.timeStringToLong(snapshot.getValue(), TimeUtils.getTimeZone()); - if (timestamp < 0) { - throw new IllegalArgumentException( - "Cannot parse Lance FOR TIME AS OF value '" + snapshot.getValue() + "'"); - } - try (Dataset latest = openDataset(allocator, tableAccess, OptionalLong.empty(), metrics)) { - version = OptionalLong.of(metrics.measure(Stage.VERSION_RESOLVE, - () -> LanceSnapshotResolver.getVersionAtOrBefore(latest, timestamp))); - } + OptionalLong direct = directMainVersion(state, metrics); Review Comment: Confirmed: the head is listed by table id before the SDK's describe, and only the location and options were reconciled afterwards. Fixed in 8e0eda26dae. `openDataset` now knows whether its version is the namespace's newest one: a plain latest read, and the main handle that tag and branch reads check out from. It lists the newest version again when the SDK opened another store than the read resolved, meaning another location (query string and trailing slash aside) or other options than credentials, such as a new `aws_endpoint` under the same URI. It does the same when the open of that version fails, since the new location may not have that version at all. When the head changed, the dataset is opened once more at the new head, and a second change asks to retry the query, as the existing option reconciliation does. Otherwise the open stands or the original error is reported, so a stale cached access costs one list, not a second open, and rotating credentials cost nothing. An explicit `FOR VERSION AS OF` keeps its number: it names that version of the table wherever the table is now. Tests, each of which fails without the change: - `LanceManagedVersioningTest.testTableMovedWhileOpeningIsReadAtItsNewestVersion`: the stub moves the table to a copy with a fourth version right after answering the FE's list; the read must plan version 4 there (9 rows), not version 3. - `LanceManagedVersioningTest.testTableMovedToAShorterHistoryIsReadAtItsNewestVersion`: the new location only has versions 1 and 2; the read must plan version 2 instead of failing with "version 3 not found". - `LanceManagedS3StoreTest.testTableMovedToAnotherEndpointWhileOpeningIsReadAtItsNewestVersion`: same URI, endpoint moved to a store one version ahead. - `LanceSdkOpenedAccessTest.testOnlyAnotherStoreOrLocationCountsAsAMove` (runs on CI): which differences count as a move. ########## fe/fe-core/src/main/java/org/apache/doris/datasource/lance/metadata/LanceSnapshotResolver.java: ########## @@ -46,26 +52,121 @@ public static long parseVersion(String value) { return version; } + private static final Pattern VERSION_NUMBER = Pattern.compile("[+-]?[0-9]+"); + /** - * Gets the latest Lance version whose commit time does not exceed the requested timestamp. - * - * <p>Called by - * {@link LanceExternalCatalog#loadTableMetadata(String, String, java.util.Optional)} to resolve - * a {@code FOR TIME AS OF} clause before loading the selected metadata snapshot. + * Whether a {@code FOR VERSION AS OF} value is a version number rather than a tag name. A + * signed number counts as one, so that {@code '-1'} is reported as an invalid version. + */ + public static boolean isVersionNumber(String value) { + return VERSION_NUMBER.matcher(value).matches(); + } + + /** No version of a chain was committed at or before the requested {@code FOR TIME AS OF} time. */ + public static final class NoVersionAtOrBeforeException extends IllegalArgumentException { + private NoVersionAtOrBeforeException(String requestedText) { + super("Lance dataset has no version at or before '" + requestedText + "'"); + } + } + + /** + * Cleanup removed a version newer than every version committed at or before the requested + * time. Its commit time went with it, so it may have been the answer. */ - public static long getVersionAtOrBefore(Dataset dataset, long timestampMillis) { - return versionAtOrBefore(dataset.listVersions(), timestampMillis); + public static final class HistoryRemovedException extends IllegalArgumentException { + private final long version; + + private HistoryRemovedException(long version) { + super("Lance version " + version + " no longer exists"); + this.version = version; + } + + public long getVersion() { + return version; + } + } + + /** The commit time a manifest records, at millisecond precision. */ + public static long commitMillis(Version version) { + return version.getDataTime().toInstant().toEpochMilli(); Review Comment: Confirmed. The JNI builds `Version.getDataTime()` from `to_rfc3339()`, which keeps the sub-millisecond digits, and flooring them made a commit at `09.113997` visible at `09.113`. The millisecond floor came in when I rewrote the resolver in an earlier round. Fixed in 8e0eda26dae. `LanceSnapshotResolver` compares the full commit `Instant` with `Instant.ofEpochMilli(requested)` and orders candidates by the full instant, equal instants by version. The regression case that expected the early-visible row is replaced: `09.114` selects version 2 and `09.113` version 1, with the `.out` regenerated. The docs no longer say millisecond precision. Tests: `LanceSnapshotTest.testTimeSelectorComparesCommitTimesInFull` covers the boundary and two commits half a millisecond apart in reverse version order. The random-history check now draws commit times in microseconds against millisecond requests. Both fail on the previous resolver. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
