LanceIndexAdmissionSnapshot.java

// 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.lance.schema.LanceField;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * One pinned latest-snapshot view of everything index admission needs from a Lance dataset:
 * the dataset version, the user logical indexes, the physical index entries (including the
 * physical index type name), and the top-level schema fields the schema contract is built
 * from. Everything is read inside a single {@code Dataset.open} call by
 * {@link LanceIndexMetadataLoader#loadAdmissionSnapshot}, and every list is materialized into
 * plain Java values before the Dataset closes — the snapshot holds no native handle, Arrow
 * allocator, or Dataset reference ({@link LanceField} is a pure POJO).
 *
 * <p>All bounds mirror the limits already enforced by {@link LanceIndexMetadataLoader}.
 * Construction validates eagerly so a malformed or oversized provider result fails closed at
 * the boundary. In particular, two physical entries sharing one name — a stale post-REPLACE
 * entry coexisting with its replacement until VACUUM — make the whole snapshot ambiguous, so
 * the constructor rejects them instead of silently keeping whichever entry the provider
 * returned first (design section 3.4).
 */
public final class LanceIndexAdmissionSnapshot {
    private final long datasetVersion;
    private final String datasetUri;
    private final List<LanceLogicalIndex> logicalIndexes;
    private final List<PhysicalIndexInfo> physicalIndexes;
    private final List<LanceField> topLevelFields;

    public LanceIndexAdmissionSnapshot(long datasetVersion, String datasetUri,
            List<LanceLogicalIndex> logicalIndexes, List<PhysicalIndexInfo> physicalIndexes,
            List<LanceField> topLevelFields) {
        if (datasetVersion <= 0) {
            throw new IllegalArgumentException("Lance dataset version must be positive");
        }
        if (datasetUri == null || datasetUri.isEmpty()) {
            throw new IllegalArgumentException("Lance dataset uri must not be null or empty");
        }
        this.datasetVersion = datasetVersion;
        // Never echoed into exception messages: the uri can carry credential userinfo, and the
        // catalog boundary sanitizes it out of provider failures.
        this.datasetUri = datasetUri;
        this.logicalIndexes = copyLogicalIndexes(logicalIndexes);
        this.physicalIndexes = copyPhysicalIndexes(physicalIndexes);
        this.topLevelFields = copyTopLevelFields(topLevelFields);
    }

    private static List<LanceLogicalIndex> copyLogicalIndexes(List<LanceLogicalIndex> source) {
        if (source == null) {
            throw new IllegalArgumentException("Lance logical indexes must not be null");
        }
        if (source.size() > LanceIndexMetadataLoader.MAX_LOGICAL_INDEXES) {
            throw new IllegalArgumentException("Lance logical index count exceeds limit "
                    + LanceIndexMetadataLoader.MAX_LOGICAL_INDEXES);
        }
        List<LanceLogicalIndex> copy = new ArrayList<>(source.size());
        for (LanceLogicalIndex index : source) {
            if (index == null) {
                throw new IllegalArgumentException("Lance logical index must not be null");
            }
            copy.add(index);
        }
        return Collections.unmodifiableList(copy);
    }

    private static List<PhysicalIndexInfo> copyPhysicalIndexes(List<PhysicalIndexInfo> source) {
        if (source == null) {
            throw new IllegalArgumentException("Lance physical index entries must not be null");
        }
        if (source.size() > LanceIndexMetadataLoader.MAX_PHYSICAL_INDEX_ENTRIES) {
            throw new IllegalArgumentException("Lance physical index entry count exceeds limit "
                    + LanceIndexMetadataLoader.MAX_PHYSICAL_INDEX_ENTRIES);
        }
        List<PhysicalIndexInfo> copy = new ArrayList<>(source.size());
        Set<String> names = new HashSet<>();
        for (PhysicalIndexInfo entry : source) {
            if (entry == null) {
                throw new IllegalArgumentException("Lance physical index entry must not be null");
            }
            // The entry name is bounded by PhysicalIndexInfo's own constructor, so echoing it
            // here keeps this failure bounded.
            if (!names.add(entry.getName())) {
                throw new IllegalArgumentException("Duplicate Lance physical index name '"
                        + entry.getName() + "' in one dataset snapshot");
            }
            copy.add(entry);
        }
        return Collections.unmodifiableList(copy);
    }

    private static List<LanceField> copyTopLevelFields(List<LanceField> source) {
        if (source == null) {
            throw new IllegalArgumentException("Lance schema fields must not be null");
        }
        if (source.size() > LanceIndexMetadataLoader.MAX_SCHEMA_FIELDS) {
            throw new IllegalArgumentException("Lance schema field count exceeds limit "
                    + LanceIndexMetadataLoader.MAX_SCHEMA_FIELDS);
        }
        List<LanceField> copy = new ArrayList<>(source.size());
        for (LanceField field : source) {
            if (field == null) {
                throw new IllegalArgumentException("Lance schema field must not be null");
            }
            copy.add(field);
        }
        return Collections.unmodifiableList(copy);
    }

    public long getDatasetVersion() {
        return datasetVersion;
    }

    public String getDatasetUri() {
        return datasetUri;
    }

    public List<LanceLogicalIndex> getLogicalIndexes() {
        return logicalIndexes;
    }

    public List<PhysicalIndexInfo> getPhysicalIndexes() {
        return physicalIndexes;
    }

    public List<LanceField> getTopLevelFields() {
        return topLevelFields;
    }

    /**
     * One physical index entry of the snapshot. Unlike the logical view, a physical entry
     * carries its manifest umbrella/concrete {@code IndexType} name (for example VECTOR or
     * IVF_PQ), which the section 3.4 family comparison needs.
     */
    public static final class PhysicalIndexInfo {
        private final String name;
        private final String uuid;
        private final long indexDatasetVersion;
        private final String indexTypeName;

        public PhysicalIndexInfo(String name, String uuid, long indexDatasetVersion,
                String indexTypeName) {
            this.name = requireBoundedString(name, "Lance physical index name");
            this.uuid = requireBoundedString(uuid, "Lance physical index uuid");
            if (indexDatasetVersion <= 0) {
                throw new IllegalArgumentException(
                        "Lance physical index dataset version must be positive");
            }
            this.indexDatasetVersion = indexDatasetVersion;
            this.indexTypeName = requireBoundedString(indexTypeName, "Lance physical index type");
        }

        private static String requireBoundedString(String value, String valueType) {
            if (value == null || value.isEmpty()) {
                throw new IllegalArgumentException(valueType + " must not be null or empty");
            }
            if (value.getBytes(StandardCharsets.UTF_8).length
                    > LanceIndexMetadataLoader.MAX_EXTERNAL_STRING_BYTES) {
                throw new IllegalArgumentException(valueType + " exceeds limit "
                        + LanceIndexMetadataLoader.MAX_EXTERNAL_STRING_BYTES + " UTF-8 bytes");
            }
            return value;
        }

        public String getName() {
            return name;
        }

        public String getUuid() {
            return uuid;
        }

        public long getIndexDatasetVersion() {
            return indexDatasetVersion;
        }

        public String getIndexTypeName() {
            return indexTypeName;
        }
    }
}