diff --git a/packaging/pom.xml b/packaging/pom.xml
index df4d33309e31..54566635f81c 100644
--- a/packaging/pom.xml
+++ b/packaging/pom.xml
@@ -453,6 +453,11 @@
hive-standalone-metastore-rest-catalog${project.version}
+
+ org.apache.hive
+ hive-standalone-metastore-search
+ ${project.version}
+ org.apache.hadoophadoop-hdfs-client
diff --git a/standalone-metastore/metastore-search/pom.xml b/standalone-metastore/metastore-search/pom.xml
new file mode 100644
index 000000000000..948357ef80ab
--- /dev/null
+++ b/standalone-metastore/metastore-search/pom.xml
@@ -0,0 +1,219 @@
+
+
+
+
+ hive-standalone-metastore
+ org.apache.hive
+ 4.3.0-SNAPSHOT
+
+ 4.0.0
+ hive-standalone-metastore-search
+ Hive Metastore Search
+
+ ..
+ 1.16.2
+ 1.16.1-beta26
+ 10.4.0
+
+
+
+ org.apache.hive
+ hive-standalone-metastore-server
+ ${hive.version}
+
+
+ org.apache.hadoop
+ hadoop-common
+
+
+ org.slf4j
+ slf4j-log4j12
+
+
+ org.slf4j
+ slf4j-reload4j
+
+
+ ch.qos.reload4j
+ reload4j
+
+
+ commons-logging
+ commons-logging
+
+
+
+
+ org.apache.hadoop
+ hadoop-hdfs-client
+
+
+ org.slf4j
+ slf4j-log4j12
+
+
+ org.slf4j
+ slf4j-reload4j
+
+
+ ch.qos.reload4j
+ reload4j
+
+
+ commons-logging
+ commons-logging
+
+
+
+
+ org.apache.commons
+ commons-lang3
+
+
+ com.google.guava
+ guava
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ dev.langchain4j
+ langchain4j
+ ${langchain4j.version}
+
+
+ dev.langchain4j
+ langchain4j-embeddings-bge-small-en-v15
+ ${langchain4j.embeddings.version}
+
+
+ org.apache.lucene
+ lucene-core
+ ${lucene.version}
+
+
+ org.apache.lucene
+ lucene-analysis-common
+ ${lucene.version}
+
+
+ org.apache.lucene
+ lucene-queryparser
+ ${lucene.version}
+
+
+ org.slf4j
+ slf4j-api
+
+
+ junit
+ junit
+ test
+
+
+ org.apache.hive
+ hive-standalone-metastore-server
+ ${hive.version}
+ tests
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+ org.apache.derby
+ derby
+ test
+
+
+ org.apache.hive.hcatalog
+ hive-hcatalog-server-extensions
+ ${hive.version}
+ test
+
+
+ org.apache.logging.log4j
+ log4j-slf4j-impl
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+
+
+ setup-test-dirs
+ process-test-resources
+
+ run
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setup-metastore-scripts
+ process-test-resources
+
+ run
+
+
+
+
+
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ true
+ false
+ -Xmx2048m --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.util.concurrent.atomic=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.nio=ALL-UNNAMED --add-opens java.base/java.util.concurrent=ALL-UNNAMED --add-opens java.base/java.util.regex=ALL-UNNAMED --add-opens java.sql/java.sql=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED
+
+ ${project.build.directory}
+ true
+ ${derby.version}
+ ${test.tmp.dir}/derby.log
+ ${test.tmp.dir}
+ jdbc:derby:memory:${test.tmp.dir}/junit_metastore_db;create=true
+ false
+ ${test.tmp.dir}
+ ${test.warehouse.scheme}${test.warehouse.dir}
+ ${test.warehouse.scheme}${test.warehouse.external.dir}
+
+
+ ${project.build.testOutputDirectory}
+
+
+
+
+
+
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/IndexConfig.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/IndexConfig.java
new file mode 100644
index 000000000000..996683b838c2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/IndexConfig.java
@@ -0,0 +1,156 @@
+/*
+ * 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.hive.search.config;
+
+import java.time.Duration;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.conf.Configuration;
+
+public record IndexConfig(Configuration configuration) {
+ public static final String INDEX_RAM_SIZE = "metastore.index.write.ram.size";
+ public static final int INDEX_RAM_SIZE_DEFAULT = 128 * 1024 * 1024;
+
+ public static final String FLUSH_INTERVAL_MS = "metastore.index.flush.interval.ms";
+ public static final long FLUSH_INTERVAL_MS_DEFAULT = 60 * 1000L;
+
+ public static final String INDEX_NAME = "metastore.index.name";
+ public static final String INDEX_NAME_DEFAULT = "hive_tables";
+
+ public static final String POLL_NOTIFICATION_INTERVAL = "metastore.index.poll.notification.ms";
+ public static final long POLL_NOTIFICATION_INTERVAL_DEFAULT = 1000;
+
+ public static final String INDEX_SYNC_INTERVAL = "metastore.index.sync.interval.minutes";
+ public static final long INDEX_SYNC_INTERVAL_DEFAULT = 360;
+
+ /** Force a metadata-only commit when this many events are ahead of the last committed checkpoint. */
+ public static final String FORCE_FLUSH_EVENT_GAP = "metastore.index.force.flush.event.gap";
+ public static final long FORCE_FLUSH_EVENT_GAP_DEFAULT = 3000L;
+
+ /** Tables per metastore fetch batch during leader bootstrap. */
+ public static final String BOOTSTRAP_BATCH_SIZE = "metastore.index.bootstrap.batch.size";
+ public static final int BOOTSTRAP_BATCH_SIZE_DEFAULT = 2000;
+
+ /** In-flight document batches between fetch workers and the index writer. */
+ public static final String BOOTSTRAP_QUEUE_DEPTH = "metastore.index.bootstrap.queue.depth";
+ public static final int BOOTSTRAP_QUEUE_DEPTH_DEFAULT = 16;
+
+ /** Parallel metastore fetch workers during bootstrap. */
+ public static final String BOOTSTRAP_FETCH_THREADS = "metastore.index.bootstrap.fetch.threads";
+ public static final int BOOTSTRAP_FETCH_THREADS_DEFAULT = 4;
+
+ /** Lucene commits after this many Lucene auto-flushes during incremental indexing. 0 commits immediately. */
+ public static final String COMMIT_FLUSHES = "metastore.index.commit.flushes";
+ public static final int COMMIT_FLUSHES_DEFAULT = 3;
+
+ /** Progress log interval during bootstrap. */
+ public static final String BOOTSTRAP_PROGRESS_INTERVAL_MS =
+ "metastore.index.bootstrap.progress.interval.ms";
+ public static final long BOOTSTRAP_PROGRESS_INTERVAL_MS_DEFAULT = 30_000L;
+
+ /** Max HMS events fetched per poll. */
+ public static final String EVENT_POLL_MAX = "metastore.index.event.poll.max";
+ public static final int EVENT_POLL_MAX_DEFAULT = 3000;
+
+ /** Consecutive batch failures before falling back to single-event apply. */
+ public static final String EVENT_BATCH_MAX_FAILURES =
+ "metastore.index.event.batch.max.failures";
+ public static final int EVENT_BATCH_MAX_FAILURES_DEFAULT = 3;
+
+ /** Extra sleep after a failed batch before the poller retries (ms). */
+ public static final String EVENT_FAILURE_BACKOFF_MS = "metastore.index.event.failure.backoff.ms";
+ public static final long EVENT_FAILURE_BACKOFF_MS_DEFAULT = 10_000L;
+
+ /** Skip a single poison event during individual fallback (index may diverge for that table). */
+ public static final String EVENT_SKIP_POISON = "metastore.index.event.skip.poison";
+ public static final boolean EVENT_SKIP_POISON_DEFAULT = false;
+
+ /** Poller sleep after index is marked unhealthy (ms). */
+ public static final String EVENT_UNHEALTHY_BACKOFF_MS = "metastore.index.event.unhealthy.backoff.ms";
+ public static final long EVENT_UNHEALTHY_BACKOFF_MS_DEFAULT = 10 * 60 * 1000L;
+
+ public int getWriteBufferSize() {
+ return configuration.getInt(INDEX_RAM_SIZE, INDEX_RAM_SIZE_DEFAULT);
+ }
+
+ public Duration getFlushInterval() {
+ return Duration.ofMillis(configuration.getLong(FLUSH_INTERVAL_MS, FLUSH_INTERVAL_MS_DEFAULT));
+ }
+
+ public double getWriteBufferSizeMb() {
+ return getWriteBufferSize() / (1024.0 * 1024.0);
+ }
+
+ public long getPollNotificationInterval() {
+ return configuration.getLong(POLL_NOTIFICATION_INTERVAL, POLL_NOTIFICATION_INTERVAL_DEFAULT);
+ }
+
+ public long getSyncInterval() {
+ return configuration.getLong(INDEX_SYNC_INTERVAL, INDEX_SYNC_INTERVAL_DEFAULT) * 60 * 1000;
+ }
+
+ public long getForceFlushEventGap() {
+ return configuration.getLong(FORCE_FLUSH_EVENT_GAP, FORCE_FLUSH_EVENT_GAP_DEFAULT);
+ }
+
+ public int getBootstrapBatchSize() {
+ return configuration.getInt(BOOTSTRAP_BATCH_SIZE, BOOTSTRAP_BATCH_SIZE_DEFAULT);
+ }
+
+ public int getBootstrapQueueDepth() {
+ return configuration.getInt(BOOTSTRAP_QUEUE_DEPTH, BOOTSTRAP_QUEUE_DEPTH_DEFAULT);
+ }
+
+ public int getBootstrapFetchThreads() {
+ return configuration.getInt(BOOTSTRAP_FETCH_THREADS, BOOTSTRAP_FETCH_THREADS_DEFAULT);
+ }
+
+ public int getCommitFlushes() {
+ return configuration.getInt(COMMIT_FLUSHES, COMMIT_FLUSHES_DEFAULT);
+ }
+
+ public long getBootstrapProgressIntervalMs() {
+ return configuration.getLong(BOOTSTRAP_PROGRESS_INTERVAL_MS,
+ BOOTSTRAP_PROGRESS_INTERVAL_MS_DEFAULT);
+ }
+
+ public int getEventPollMax() {
+ return configuration.getInt(EVENT_POLL_MAX, EVENT_POLL_MAX_DEFAULT);
+ }
+
+ public int getEventBatchMaxFailures() {
+ return configuration.getInt(EVENT_BATCH_MAX_FAILURES, EVENT_BATCH_MAX_FAILURES_DEFAULT);
+ }
+
+ public long getEventFailureBackoffMs() {
+ return configuration.getLong(EVENT_FAILURE_BACKOFF_MS, EVENT_FAILURE_BACKOFF_MS_DEFAULT);
+ }
+
+ public boolean isEventSkipPoison() {
+ return configuration.getBoolean(EVENT_SKIP_POISON, EVENT_SKIP_POISON_DEFAULT);
+ }
+
+ public long getEventUnhealthyBackoffMs() {
+ return configuration.getLong(EVENT_UNHEALTHY_BACKOFF_MS, EVENT_UNHEALTHY_BACKOFF_MS_DEFAULT);
+ }
+
+ public String indexName() {
+ String name = configuration.get(INDEX_NAME);
+ return StringUtils.isEmpty(name) ? INDEX_NAME_DEFAULT : name;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/IndexStateConfig.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/IndexStateConfig.java
new file mode 100644
index 000000000000..f30a82131cb1
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/IndexStateConfig.java
@@ -0,0 +1,64 @@
+/*
+ * 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.hive.search.config;
+
+import java.net.URI;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.exception.IndexIOException;
+
+public record IndexStateConfig(Configuration configuration, String indexName) {
+ public static final String LOCAL_PATH = "metastore.index.local.path";
+ public static final String REMOTE_URI = "metastore.index.backup.remote.uri";
+ public static final String MEMORY = "metastore.index.use.memory";
+
+ public static final Path DEFAULT_WORKDIR = Paths.get(System.getProperty("user.dir"), "indexes");
+
+ public Path getLocalPath() {
+ String path = configuration.get(LOCAL_PATH, DEFAULT_WORKDIR.toString());
+ return Path.of(path);
+ }
+
+ public String getRemoteUri() {
+ return configuration.get(REMOTE_URI, "");
+ }
+
+ public boolean hasRemote() {
+ String uri = getRemoteUri();
+ return StringUtils.isNotEmpty(uri);
+ }
+
+ public boolean useMemory() {
+ return configuration.getBoolean(MEMORY, false);
+ }
+
+ public boolean isDistributed() {
+ return hasRemote();
+ }
+
+ public static void validateRemoteUri(String uriText) throws IndexIOException {
+ try {
+ URI.create(uriText);
+ } catch (IllegalArgumentException e) {
+ throw new IndexIOException("invalid store.remote.uri: " + uriText, e);
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/InferenceConfig.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/InferenceConfig.java
new file mode 100644
index 000000000000..89104bc2e69a
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/InferenceConfig.java
@@ -0,0 +1,130 @@
+/*
+ * 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.hive.search.config;
+
+import java.io.IOException;
+import java.net.URI;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileStatus;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hive.search.exception.InitializeException;
+import org.apache.hive.search.inference.EmbeddingPrompt;
+
+public record InferenceConfig(Configuration configuration) {
+ public static final String MODEL_ONNX_FILE = "model.onnx";
+ public static final String TOKENIZER = "tokenizer.json";
+
+ public static final String MODEL_LOCAL_DIR = "metastore.inference.local.dir";
+ public static final String MODEL_LOCAL_DIR_DEFAULT = System.getProperty("java.io.tmpdir") + "/hivesearch-cache";
+ public static final String MODEL_REMOTE_DIR = "metastore.inference.remote.dir";
+
+ public static final String MODEL_NAME = "metastore.inference.embedding.name";
+ public static final String EMBEDDING_PROMPT_DOC = "metastore.inference.embedding.prompt.doc";
+ public static final String EMBEDDING_PROMPT_QUERY = "metastore.inference.embedding.prompt.query";
+
+ public static final String EMBEDDING_CACHE_ENABLED = "metastore.inference.embedding.cache.enabled";
+ public static final boolean EMBEDDING_CACHE_ENABLED_DEFAULT = true;
+
+ public static final String EMBEDDING_CACHE_MAX_ENTRIES = "metastore.inference.embedding.cache.max.entries";
+ public static final int EMBEDDING_CACHE_MAX_ENTRIES_DEFAULT = 100_000;
+
+ public EmbeddingModelSpec embedding() throws InitializeException, IOException {
+ String modelName = modelName();
+ if (StringUtils.isEmpty(modelName)) {
+ throw new InitializeException("No model configured for embedding the search");
+ }
+ URI modelPath = URI.create(configuration.get(MODEL_LOCAL_DIR, MODEL_LOCAL_DIR_DEFAULT));
+ Path lPath = new Path("file://" + modelPath.getPath());
+ if (requireConfigured(lPath, modelName)) {
+ String remotePath = configuration.get(MODEL_REMOTE_DIR);
+ String message = "Can't find the model or tokenizer in %s for " + modelName;
+ if (StringUtils.isEmpty(remotePath)) {
+ throw new InitializeException(String.format(message, lPath));
+ }
+ Path rPath = new Path(remotePath);
+ if (requireConfigured(rPath, modelName)) {
+ throw new InitializeException(String.format(message, rPath));
+ }
+ Path dest = new Path(lPath, modelName);
+ Path src = new Path(rPath, modelName);
+ lPath.getFileSystem(configuration).mkdirs(dest);
+ FileSystem fileSystem = rPath.getFileSystem(configuration);
+ fileSystem.copyToLocalFile(new Path(src, MODEL_ONNX_FILE), new Path(dest, MODEL_ONNX_FILE));
+ fileSystem.copyToLocalFile(new Path(src, TOKENIZER), new Path(dest, TOKENIZER));
+ }
+ EmbeddingPrompt prompt = new EmbeddingPrompt(configuration.get(EMBEDDING_PROMPT_DOC, "passage: "),
+ configuration.get(EMBEDDING_PROMPT_QUERY, "query: "));
+ return new EmbeddingModelSpec(modelName, new Path(lPath, modelName), prompt);
+ }
+
+ private boolean requireConfigured(Path modelPath, String modelName) {
+ try {
+ FileSystem fileSystem = modelPath.getFileSystem(configuration);
+ FileStatus[] fileStatuses = fileSystem.listStatus(new Path(modelPath, modelName), path -> {
+ String fileName = path.getName();
+ return fileName.equals(MODEL_ONNX_FILE) || fileName.equals(TOKENIZER);
+ });
+ if (fileStatuses == null || fileStatuses.length != 2) {
+ return true;
+ }
+ return fileStatuses[0].isDirectory() || fileStatuses[1].isDirectory();
+ } catch (IOException e) {
+ return true;
+ }
+ }
+
+ public String modelName() {
+ return configuration.get(MODEL_NAME);
+ }
+
+ public boolean isEmbeddingCacheEnabled() {
+ return configuration.getBoolean(EMBEDDING_CACHE_ENABLED, EMBEDDING_CACHE_ENABLED_DEFAULT);
+ }
+
+ public int getEmbeddingCacheMaxEntries() {
+ return configuration.getInt(EMBEDDING_CACHE_MAX_ENTRIES, EMBEDDING_CACHE_MAX_ENTRIES_DEFAULT);
+ }
+
+ public static class EmbeddingModelSpec {
+ private final String model;
+ private final java.nio.file.Path modelDir;
+ private final EmbeddingPrompt prompt;
+
+ public EmbeddingModelSpec (String model, Path path, EmbeddingPrompt promp) {
+ this.model = model;
+ this.prompt = promp == null ?
+ EmbeddingPrompt.none() : promp;
+ this.modelDir = java.nio.file.Path.of(path.toUri());
+ }
+
+ public String getModel() {
+ return model;
+ }
+
+ public java.nio.file.Path getModelDir() {
+ return modelDir;
+ }
+
+ public EmbeddingPrompt getPrompt() {
+ return prompt;
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/SearchConfig.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/SearchConfig.java
new file mode 100644
index 000000000000..1b05c69f2ea2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/config/SearchConfig.java
@@ -0,0 +1,91 @@
+/*
+ * 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.hive.search.config;
+
+import java.time.Duration;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.exception.SearchException;
+
+public record SearchConfig(Configuration configuration) {
+ public static final String REFRESH_INTERVAL_SECONDS = "metastore.search.refresh.interval.seconds";
+ public static final long REFRESH_INTERVAL_SECONDS_DEFAULT = 1L;
+
+ public static final String DEFAULT_LIMIT = "metastore.search.default.limit";
+ public static final int DEFAULT_LIMIT_DEFAULT = 10;
+
+ public static final String INIT_READY_TIMEOUT_MS = "metastore.search.init.ready.timeout.ms";
+ public static final long INIT_READY_TIMEOUT_MS_DEFAULT = 300L;
+
+ public static final String HYBRID_SEMANTIC_WEIGHT = "metastore.search.hybrid.semantic.weight";
+ public static final float HYBRID_SEMANTIC_WEIGHT_DEFAULT = 0.4f;
+
+ public static final String FUSION_PRIOR = "metastore.search.fusion.prior";
+ public static final float FUSION_PRIOR_DEFAULT = 0.5f;
+
+ public static final String BAYESIAN_SAMPLES = "metastore.search.bayesian.samples";
+ public static final int BAYESIAN_SAMPLES_DEFAULT = 100;
+
+ public static final String BAYESIAN_TOKENS_PER_QUERY = "metastore.search.bayesian.tokens.per.query";
+ public static final int BAYESIAN_TOKENS_PER_QUERY_DEFAULT = 5;
+
+ public static final String BAYESIAN_SEED = "metastore.search.bayesian.seed";
+ public static final long BAYESIAN_SEED_DEFAULT = 42L;
+
+ public Duration getRefreshInterval() {
+ return Duration.ofSeconds(
+ configuration.getLong(REFRESH_INTERVAL_SECONDS, REFRESH_INTERVAL_SECONDS_DEFAULT));
+ }
+
+ public int getDefaultLimit() {
+ return configuration.getInt(DEFAULT_LIMIT, DEFAULT_LIMIT_DEFAULT);
+ }
+
+ public long getInitReadyTimeoutMs() {
+ return configuration.getLong(INIT_READY_TIMEOUT_MS, INIT_READY_TIMEOUT_MS_DEFAULT);
+ }
+
+ public float getHybridSemanticWeight() throws SearchException {
+ float weight = configuration.getFloat(HYBRID_SEMANTIC_WEIGHT, HYBRID_SEMANTIC_WEIGHT_DEFAULT);
+ if (weight >= 1.0f || weight <= 0.0f) {
+ throw new SearchException("Invalid hybrid semantic weight, " +
+ "it must be in (0, 1), but got " + weight);
+ }
+ return weight;
+ }
+
+ public float getHybridMatchWeight() throws SearchException {
+ return 1.0f - getHybridSemanticWeight();
+ }
+
+ public float getFusionPrior() {
+ return configuration.getFloat(FUSION_PRIOR, FUSION_PRIOR_DEFAULT);
+ }
+
+ public int getBayesianSamples() {
+ return configuration.getInt(BAYESIAN_SAMPLES, BAYESIAN_SAMPLES_DEFAULT);
+ }
+
+ public int getBayesianTokensPerQuery() {
+ return configuration.getInt(BAYESIAN_TOKENS_PER_QUERY, BAYESIAN_TOKENS_PER_QUERY_DEFAULT);
+ }
+
+ public long getBayesianSeed() {
+ return configuration.getLong(BAYESIAN_SEED, BAYESIAN_SEED_DEFAULT);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexException.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexException.java
new file mode 100644
index 000000000000..24a5dd13f801
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexException.java
@@ -0,0 +1,36 @@
+/*
+ * 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.hive.search.exception;
+
+/** Failure while building, updating, or embedding content for the search index. */
+public class IndexException extends Exception {
+ public IndexException(String message) {
+ super(message);
+ }
+
+ public IndexException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public static IndexException wrap(String message, Throwable cause) {
+ if (cause instanceof IndexException indexException) {
+ return indexException;
+ }
+ return new IndexException(message, cause);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexIOException.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexIOException.java
new file mode 100644
index 000000000000..dcb7685805b6
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexIOException.java
@@ -0,0 +1,38 @@
+/*
+ * 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.hive.search.exception;
+
+import java.io.IOException;
+
+/** Failure while reading, writing, syncing, or restoring index files and manifests. */
+public class IndexIOException extends IOException {
+ public IndexIOException(String message) {
+ super(message);
+ }
+
+ public IndexIOException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public static IndexIOException wrap(Exception cause) {
+ if (cause instanceof IndexIOException indexIOException) {
+ return indexIOException;
+ }
+ return new IndexIOException(cause.getMessage(), cause);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexNotHealthyException.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexNotHealthyException.java
new file mode 100644
index 000000000000..da1d5f2f5689
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexNotHealthyException.java
@@ -0,0 +1,30 @@
+/*
+ * 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.hive.search.exception;
+
+import java.io.IOException;
+
+public class IndexNotHealthyException extends IOException {
+ public IndexNotHealthyException(String message) {
+ super(message);
+ }
+
+ public IndexNotHealthyException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexNotReadyException.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexNotReadyException.java
new file mode 100644
index 000000000000..518bb42d65df
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/IndexNotReadyException.java
@@ -0,0 +1,30 @@
+/*
+ * 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.hive.search.exception;
+
+import java.io.IOException;
+
+public class IndexNotReadyException extends IOException {
+ public IndexNotReadyException(String message) {
+ super(message);
+ }
+
+ public IndexNotReadyException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/InitializeException.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/InitializeException.java
new file mode 100644
index 000000000000..77e935b5e1c1
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/InitializeException.java
@@ -0,0 +1,36 @@
+/*
+ * 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.hive.search.exception;
+
+/** Failure while opening or bootstrapping the search service and its dependencies. */
+public class InitializeException extends Exception {
+ public InitializeException(String message) {
+ super(message);
+ }
+
+ public InitializeException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public static InitializeException wrap(String message, Throwable cause) {
+ if (cause instanceof InitializeException initializeException) {
+ return initializeException;
+ }
+ return new InitializeException(message, cause);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/SearchException.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/SearchException.java
new file mode 100644
index 000000000000..e68e1a929a7b
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/exception/SearchException.java
@@ -0,0 +1,29 @@
+/*
+ * 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.hive.search.exception;
+
+/** Failure while executing or preparing a table search request. */
+public class SearchException extends Exception {
+ public SearchException(String message) {
+ super(message);
+ }
+
+ public SearchException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/FlushTrackingWriter.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/FlushTrackingWriter.java
new file mode 100644
index 000000000000..ff3b13620f01
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/FlushTrackingWriter.java
@@ -0,0 +1,48 @@
+/*
+ * 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.hive.search.index;
+
+import java.io.IOException;
+
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.store.Directory;
+
+/** Tracks Lucene auto-flushes so callers can gate commits separately. */
+final class FlushTrackingWriter extends IndexWriter {
+ private int flushesSinceCommit;
+
+ FlushTrackingWriter(Directory directory, IndexWriterConfig config)
+ throws IOException {
+ super(directory, config);
+ }
+
+ int flushesSinceCommit() {
+ return flushesSinceCommit;
+ }
+
+ void resetFlushTracking() {
+ flushesSinceCommit = 0;
+ }
+
+ @Override
+ protected void doAfterFlush() throws IOException {
+ super.doAfterFlush();
+ flushesSinceCommit++;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/IndexManager.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/IndexManager.java
new file mode 100644
index 000000000000..3fb795b840ab
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/IndexManager.java
@@ -0,0 +1,184 @@
+/*
+ * 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.hive.search.index;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Optional;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.exception.IndexNotHealthyException;
+import org.apache.hive.search.index.manifest.IndexManifest;
+import org.apache.hive.search.exception.IndexIOException;
+import org.apache.hive.search.index.store.LocalStateClient;
+import org.apache.hive.search.index.store.IndexBackupUtils;
+import org.apache.hive.search.index.store.IndexStateClient;
+import org.apache.hive.search.config.IndexStateConfig;
+import org.apache.hive.search.metastore.MetastoreEventListener;
+import org.apache.lucene.store.ByteBuffersDirectory;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.MMapDirectory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class IndexManager implements AutoCloseable, MetastoreEventListener {
+ private static final Logger LOG = LoggerFactory.getLogger(IndexManager.class);
+ private final IndexMapping mapping;
+ private final Directory directory;
+ private final IndexStateClient localIndex;
+ private final IndexStateClient remoteIndex;
+ private volatile IndexNotHealthyException exception;
+
+ public IndexManager(IndexMapping mapping, Directory directory, IndexStateClient localIndex,
+ IndexStateClient remoteIndex) {
+ this.mapping = mapping;
+ this.directory = directory;
+ this.localIndex = localIndex;
+ this.remoteIndex = remoteIndex;
+ }
+
+ public static IndexManager open(IndexMapping mapping, Configuration conf) throws IOException {
+ IndexStateConfig store = mapping.store();
+ Directory directory;
+ if (store.useMemory()) {
+ directory = new ByteBuffersDirectory();
+ } else {
+ directory = openDiskDirectory(mapping.indexName(), store.getLocalPath());
+ }
+
+ IndexStateClient local =
+ new LocalStateClient(directory, mapping.indexName());
+ IndexStateClient remote = null;
+ if (store.hasRemote()) {
+ remote = IndexBackupUtils.openRemote(store.getRemoteUri(), mapping.indexName(), conf);
+ }
+ return new IndexManager(mapping, directory, local, remote);
+ }
+
+ private static Directory openDiskDirectory(String indexName, Path basePath) throws IOException {
+ Path indexPath = basePath.resolve(indexName);
+ Files.createDirectories(indexPath);
+ return new MMapDirectory(indexPath);
+ }
+
+ public boolean hasBackup() {
+ return remoteIndex != null;
+ }
+
+ public boolean syncBackup() throws IOException {
+ if (remoteIndex == null) {
+ return false;
+ }
+ if (localIndex.readManifest().isEmpty()) {
+ return false;
+ }
+ return IndexBackupUtils.syncToBackup(localIndex, remoteIndex);
+ }
+
+ public boolean restoreBackup() {
+ try {
+ if (remoteIndex == null) {
+ return false;
+ }
+ return IndexBackupUtils.restoreFromBackup(localIndex, remoteIndex);
+ } catch (IOException e) {
+ LOG.warn("Cannot restore the index from remote backup", e);
+ return false;
+ }
+ }
+
+ public void resolveInterruptedRestore() {
+ try {
+ IndexBackupUtils.resolveInterruptedRestore(localIndex);
+ } catch (IOException e) {
+ LOG.warn("Cannot resolve interrupted local index restore", e);
+ }
+ }
+
+ public Optional readLocalManifest() {
+ try {
+ return localIndex.readManifest();
+ } catch (IOException e) {
+ LOG.warn("Cannot read the local index manifest", e);
+ return Optional.empty();
+ }
+ }
+
+ public Optional readRemoteManifest() {
+ try {
+ if (remoteIndex == null) {
+ return Optional.empty();
+ }
+ return remoteIndex.readManifest();
+ } catch (IOException e) {
+ LOG.warn("Cannot read the remote index manifest", e);
+ return Optional.empty();
+ }
+ }
+
+ public void clearLocalIndex() throws IOException {
+ localIndex.clear();
+ }
+
+ public void clearRemoteIndex() throws IOException {
+ if (remoteIndex != null) {
+ remoteIndex.clear();
+ }
+ }
+
+ public void notifyIndexState(boolean healthy, IndexNotHealthyException... e) {
+ if (healthy) {
+ exception = null;
+ return;
+ }
+ if (e.length > 0 && e[0] != null) {
+ exception = e[0];
+ }
+ }
+
+ public void checkIndexState() throws IndexNotHealthyException {
+ if (exception != null) {
+ throw exception;
+ }
+ }
+
+ public IndexMapping mapping() {
+ return mapping;
+ }
+
+ public Directory directory() {
+ return directory;
+ }
+
+ @Override
+ public void close() throws IOException {
+ directory.close();
+ if (remoteIndex instanceof AutoCloseable closeable) {
+ try {
+ closeable.close();
+ } catch (Exception e) {
+ if (e instanceof IOException ioe) {
+ throw ioe;
+ }
+ throw new IndexIOException("Failed to close remote index client", e);
+ }
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/IndexSession.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/IndexSession.java
new file mode 100644
index 000000000000..4632835f4ec0
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/IndexSession.java
@@ -0,0 +1,122 @@
+/*
+ * 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.hive.search.index;
+
+import java.io.IOException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.exception.IndexNotReadyException;
+import org.apache.hive.search.exception.InitializeException;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.hive.search.metastore.MetastoreIndexer;
+import org.apache.hive.search.metastore.MetastoreSchemas;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.apache.hive.search.search.SearchInternal;
+import org.apache.lucene.search.BayesianScoreEstimator;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.SearcherManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Colocated indexer and searcher */
+public final class IndexSession implements AutoCloseable {
+ private static final Logger LOG = LoggerFactory.getLogger(IndexSession.class);
+ private final Configuration configuration;
+ private final IndexManager indexManager;
+ private final EmbedModelRegistry modelRegistry;
+ private final SearchConfig searchConfig;
+
+ private BayesianScoreEstimator.Parameters parameters;
+ private MetastoreIndexer metastoreIndexer;
+ private SearcherManager searcherManager;
+ private Indexer indexer;
+
+ private final ScheduledExecutorService service = Executors.newScheduledThreadPool(1);
+
+ public IndexSession(Configuration configuration)
+ throws InitializeException, IOException {
+ this.configuration = configuration;
+ IndexConfig indexConfig = new IndexConfig(configuration);
+ this.searchConfig = new SearchConfig(configuration);
+ InferenceConfig inferenceConfig = new InferenceConfig(configuration);
+ this.indexManager = IndexManager.open(
+ MetastoreSchemas.defaultHiveTablesMapping(indexConfig.indexName(),
+ inferenceConfig.modelName(), configuration), configuration);
+ this.modelRegistry = EmbedModelRegistry.create(configuration);
+ }
+
+ public void maybeRefreshIndex() {
+ try {
+ searcherManager.maybeRefresh();
+ } catch (IOException e) {
+ LOG.info("Error while refreshing the index", e);
+ }
+ }
+
+ public void initialize() throws Exception {
+ indexer = new Indexer(indexManager, modelRegistry);
+ metastoreIndexer = new MetastoreIndexer(configuration, indexManager, indexer);
+ metastoreIndexer.start();
+ searcherManager = new SearcherManager(indexer.writer(), null);
+ service.scheduleAtFixedRate(this::maybeRefreshIndex, 0,
+ searchConfig.getRefreshInterval().toSeconds(), TimeUnit.SECONDS);
+ IndexSearcher searcher = searcherManager.acquire();
+ try {
+ parameters = BayesianScoreEstimator.estimate(searcher,
+ MetastoreTableMapper.FIELD_SEARCH_TEXT,
+ searchConfig.getBayesianSamples(),
+ searchConfig.getBayesianTokensPerQuery(),
+ searchConfig.getBayesianSeed());
+ LOG.info("BayesianScore alpha={} beta={} baseRate={}",
+ parameters.alpha(), parameters.beta(), parameters.baseRate());
+ } finally {
+ searcherManager.release(searcher);
+ }
+ }
+
+ public SearchInternal getSearcher() throws IOException {
+ if (parameters == null) {
+ throw new IndexNotReadyException("Index session is not ready for search requests");
+ }
+ indexManager.checkIndexState();
+ return new SearchInternal(searcherManager, indexManager,
+ modelRegistry, searchConfig, parameters);
+ }
+
+ @Override
+ public void close() throws Exception {
+ service.shutdown();
+ if (searcherManager != null) {
+ searcherManager.close();
+ }
+ if (metastoreIndexer != null) {
+ metastoreIndexer.close();
+ }
+ if (indexer != null) {
+ indexer.close();
+ }
+ indexManager.close();
+ modelRegistry.close();
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/Indexer.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/Indexer.java
new file mode 100644
index 000000000000..6ce90b4de0c8
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/Indexer.java
@@ -0,0 +1,301 @@
+/*
+ * 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.hive.search.index;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ListMultimap;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.hadoop.hive.common.DatabaseName;
+import org.apache.hadoop.hive.metastore.Batchable;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.inference.EmbedModel;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.hive.search.inference.EmbeddingCache;
+import org.apache.hive.search.mapping.FieldSchema;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.mapping.field.Field;
+import org.apache.hive.search.mapping.field.TextField;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.IndexCommit;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.KeepOnlyLastCommitDeletionPolicy;
+import org.apache.lucene.index.SnapshotDeletionPolicy;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.BooleanClause;
+import org.apache.lucene.search.BooleanQuery;
+import org.apache.lucene.search.PrefixQuery;
+import org.apache.lucene.search.Query;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class Indexer implements AutoCloseable {
+ private static final Logger LOG = LoggerFactory.getLogger(Indexer.class);
+ private static final int EMBED_BATCH_SIZE = 10000;
+
+ private final IndexManager indexManager;
+ private final EmbedModelRegistry modelRegistry;
+ private final EmbeddingCache embeddingCache;
+ private final int commitFlushThreshold;
+ private SnapshotDeletionPolicy snapshotter;
+ private FlushTrackingWriter writer;
+
+ public Indexer(IndexManager index, EmbedModelRegistry registry) {
+ this.indexManager = index;
+ this.modelRegistry = registry;
+ this.embeddingCache = registry.embeddingCache();
+ this.commitFlushThreshold =
+ new IndexConfig(index.mapping().configuration()).getCommitFlushes();
+ }
+
+ int flushesSinceCommit() {
+ return writer.flushesSinceCommit();
+ }
+
+ public void initialize() throws IOException {
+ IndexWriterConfig config =
+ new IndexWriterConfig(indexManager.mapping().analyzer())
+ .setCommitOnClose(false)
+ .setRAMBufferSizeMB(indexManager.mapping().config().getWriteBufferSizeMb());
+ SnapshotDeletionPolicy snapshotDeletionPolicy =
+ new SnapshotDeletionPolicy(new KeepOnlyLastCommitDeletionPolicy());
+ config.setIndexDeletionPolicy(snapshotDeletionPolicy);
+ this.snapshotter = snapshotDeletionPolicy;
+ this.writer = new FlushTrackingWriter(indexManager.directory(), config);
+ }
+
+ public void syncBackup() throws IOException {
+ if (!indexManager.hasBackup()) {
+ return;
+ }
+ IndexCommit snapshot = snapshotter.snapshot();
+ try {
+ indexManager.syncBackup();
+ } finally {
+ snapshotter.release(snapshot);
+ }
+ }
+
+ /** Writes already-embedded documents to Lucene. */
+ private void writeDocuments(List docs) throws IOException, IndexException {
+ List luceneDocs = new ArrayList<>();
+ List ids = new ArrayList<>();
+ for (TableDocument doc : docs) {
+ ids.add(doc.idField().value());
+ luceneDocs.addAll(doc.toDocuments());
+ }
+ delete(ids.toArray(new String[0]));
+ writer.addDocuments(luceneDocs);
+ }
+
+ public List embedDocuments(List tableDocs)
+ throws IndexException {
+ long start = System.currentTimeMillis();
+ List result = new ArrayList<>(tableDocs.size());
+ Map> modelPerTxt = new HashMap<>();
+ for (TableDocument doc : tableDocs) {
+ TableDocument newDoc = new TableDocument(doc.idField(), List.of(), indexManager.mapping());
+ result.add(newDoc);
+ for (Field field : doc.fields()) {
+ if (field instanceof org.apache.hive.search.mapping.field.IdField) {
+ continue;
+ }
+ if (field instanceof TextField text) {
+ FieldSchema schema = indexManager.mapping().fieldSchema(text.name());
+ if (schema instanceof FieldSchema.TextFieldSchema textSchema
+ && textSchema.search().semantic()) {
+ if (text.embedding() != null) {
+ newDoc.appendField(field);
+ } else {
+ String modelRef = textSchema.search().semanticModel();
+ ListMultimap multimapText =
+ modelPerTxt.computeIfAbsent(modelRef, s -> ArrayListMultimap.create());
+ multimapText.put(text, newDoc);
+ }
+ } else {
+ newDoc.appendField(field);
+ }
+ } else {
+ newDoc.appendField(field);
+ }
+ }
+ }
+ int totalFields = modelPerTxt.values().stream().mapToInt(ListMultimap::size).sum();
+ long cacheHitsBefore = embeddingCache.hits();
+ long cacheMissesBefore = embeddingCache.misses();
+ for (Map.Entry> entry : modelPerTxt.entrySet()) {
+ embedInBatch(entry.getKey(), modelRegistry.get(entry.getKey()), entry.getValue());
+ }
+ if (totalFields > 0) {
+ long cacheHits = embeddingCache.hits() - cacheHitsBefore;
+ long cacheMisses = embeddingCache.misses() - cacheMissesBefore;
+ LOG.info("Embedded {} semantic field(s) across {} document(s) in {}ms"
+ + " (embedding cache hits={}, misses={})",
+ totalFields, tableDocs.size(), System.currentTimeMillis() - start, cacheHits, cacheMisses);
+ }
+ return result;
+ }
+
+ private void embedInBatch(String modelRef, EmbedModel embedModel,
+ ListMultimap textDocs) throws IndexException {
+ int uniqueTexts = textDocs.keySet().stream()
+ .map(TextField::value)
+ .collect(java.util.stream.Collectors.toSet())
+ .size();
+ long start = System.currentTimeMillis();
+ try {
+ Batchable.runBatched(EMBED_BATCH_SIZE, new ArrayList<>(textDocs.keySet()),
+ new Batchable() {
+ @Override
+ public List run(List batchFields) throws IndexException {
+ long batchStart = System.currentTimeMillis();
+ ListMultimap valueToTxt = ArrayListMultimap.create();
+ batchFields.forEach(f -> valueToTxt.put(f.value(), f));
+ List missTexts = new ArrayList<>();
+ for (String text : valueToTxt.keySet()) {
+ Optional cached =
+ embeddingCache.get(modelRef, EmbedModel.TaskType.DOCUMENT, text);
+ if (cached.isPresent()) {
+ applyEmbedding(valueToTxt, textDocs, text, cached.get());
+ } else {
+ missTexts.add(text);
+ }
+ }
+ if (!missTexts.isEmpty()) {
+ String[] texts = missTexts.toArray(new String[0]);
+ float[][] embeddings = embedModel.embedBatch(EmbedModel.TaskType.DOCUMENT, texts);
+ for (int i = 0; i < texts.length; i++) {
+ String text = texts[i];
+ float[] embedding = embeddings[i];
+ embeddingCache.put(modelRef, EmbedModel.TaskType.DOCUMENT, text, embedding);
+ applyEmbedding(valueToTxt, textDocs, text, embedding);
+ }
+ }
+ LOG.debug("Model '{}' embedded batch of {} unique text(s), {} cache miss(es) in {}ms",
+ modelRef, valueToTxt.keySet().size(), missTexts.size(),
+ System.currentTimeMillis() - batchStart);
+ return List.of();
+ }
+ });
+ } catch (Exception e) {
+ throw IndexException.wrap("Error while embedding the documents with model '" + modelRef + "'",
+ e);
+ }
+ LOG.info("Model '{}' embedded {} field(s) from {} unique text(s) in {}ms",
+ modelRef, textDocs.size(), uniqueTexts, System.currentTimeMillis() - start);
+ }
+
+ private void applyEmbedding(ListMultimap valueToTxt,
+ ListMultimap textDocs, String text, float[] embedding) {
+ for (TextField tf : valueToTxt.get(text)) {
+ for (TableDocument document : textDocs.get(tf)) {
+ document.appendField(tf.withEmbedding(embedding));
+ }
+ }
+ }
+
+ public void addDocuments(List docs) throws IOException, IndexException {
+ writeDocuments(embedDocuments(docs));
+ }
+
+ public IndexWriter writer() {
+ return writer;
+ }
+
+ public EmbeddingCache embeddingCache() {
+ return embeddingCache;
+ }
+
+ /**
+ * Commits the index when forced or after enough Lucene auto-flushes.
+ * Lucene continues to flush segments automatically based on RAM buffer settings.
+ */
+ public boolean flush(long lastEventId, boolean force)
+ throws IOException {
+ if (!force && (!hasPendingChanges() || !shouldCommit())) {
+ return false;
+ }
+ String model = indexManager.mapping().inference().modelName();
+ Map metadata = Map.of(
+ "nid", lastEventId + "",
+ "model", model,
+ "commit_time", String.valueOf(System.currentTimeMillis())
+ );
+ writer.setLiveCommitData(metadata.entrySet());
+ long seqnum = writer.commit();
+ if (seqnum < 0) {
+ return false;
+ }
+ writer.resetFlushTracking();
+ return true;
+ }
+
+ private boolean shouldCommit() {
+ return commitFlushThreshold <= 0 || writer.flushesSinceCommit() >= commitFlushThreshold;
+ }
+
+ private boolean hasPendingChanges() {
+ return writer.hasUncommittedChanges()
+ || writer.numRamDocs() > 0
+ || writer.hasDeletions();
+ }
+
+ public int delete(String... docIds) throws IOException {
+ if (docIds == null || docIds.length == 0) {
+ return 0;
+ }
+ int before = writer.getDocStats().numDocs;
+ Term[] terms = Arrays.stream(docIds)
+ .map(id -> new Term("_id" + TableDocument.FILTER_SUFFIX, id))
+ .toArray(Term[]::new);
+ writer.deleteDocuments(terms);
+ int after = writer.getDocStats().numDocs;
+ return before - after;
+ }
+
+ public int deleteDatabases(DatabaseName... databases)
+ throws IOException {
+ if (databases == null || databases.length == 0) {
+ return 0;
+ }
+ int before = writer.getDocStats().numDocs;
+ BooleanQuery.Builder builder = new BooleanQuery.Builder();
+ for (DatabaseName database : databases) {
+ Query query = new PrefixQuery(new Term("_id" + TableDocument.FILTER_SUFFIX,
+ database.getCat() + "." + database.getDb() + "."));
+ builder.add(query, BooleanClause.Occur.SHOULD);
+ }
+ writer.deleteDocuments(builder.build());
+ int after = writer.getDocStats().numDocs;
+ return before - after;
+ }
+
+ @Override
+ public void close() throws IOException {
+ writer.close();
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/manifest/IndexManifest.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/manifest/IndexManifest.java
new file mode 100644
index 000000000000..9f9544003620
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/manifest/IndexManifest.java
@@ -0,0 +1,100 @@
+/*
+ * 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.hive.search.index.manifest;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record IndexManifest(String indexName, List files, String modelName, long lastEventId) {
+ public static final String MANIFEST_FILE_NAME = "index.json";
+ /** Local-only staging copy of the remote manifest while a restore is in progress. */
+ public static final String STAGING_MANIFEST_FILE_NAME = ".index.json";
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ public record IndexFile(String name, long size) {}
+
+ public static IndexManifest create(String indexName, List files,
+ String modelName, long lastEventId) {
+ return new IndexManifest(indexName, List.copyOf(files), modelName, lastEventId);
+ }
+
+ public byte[] toJsonBytes() throws IOException {
+ return JSON.writerWithDefaultPrettyPrinter().writeValueAsBytes(this);
+ }
+
+ public static IndexManifest fromJson(byte[] bytes) throws IOException {
+ return JSON.readValue(bytes, IndexManifest.class);
+ }
+
+ public boolean sameFilesAs(IndexManifest other) {
+ return other != null && toMap(files).equals(toMap(other.files()));
+ }
+
+ public List diff(IndexManifest target) {
+ Map sourceMap = toMap(files);
+ Map destMap = target == null ? Map.of() : toMap(target.files());
+ HashSet keys = new HashSet<>();
+ keys.addAll(sourceMap.keySet());
+ keys.addAll(destMap.keySet());
+
+ List ops = new ArrayList<>();
+ for (String key : keys) {
+ Long sourceSize = sourceMap.get(key);
+ Long destSize = destMap.get(key);
+ if (sourceSize != null && sourceSize.equals(destSize)) {
+ continue;
+ }
+ if (sourceSize != null) {
+ ops.add(ChangedFileOp.add(key, sourceSize));
+ } else if (destSize != null) {
+ ops.add(ChangedFileOp.del(key));
+ }
+ }
+ return ops;
+ }
+
+ private static Map toMap(List files) {
+ HashMap map = new HashMap<>();
+ for (IndexFile file : files) {
+ map.put(file.name(), file.size());
+ }
+ return map;
+ }
+
+ public interface ChangedFileOp {
+ record Add(String fileName, Long size) implements ChangedFileOp {}
+
+ record Del(String fileName) implements ChangedFileOp {}
+
+ static Add add(String fileName, Long size) {
+ return new Add(fileName, size);
+ }
+
+ static Del del(String fileName) {
+ return new Del(fileName);
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/IndexBackupUtils.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/IndexBackupUtils.java
new file mode 100644
index 000000000000..697e0f22f2ca
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/IndexBackupUtils.java
@@ -0,0 +1,121 @@
+/*
+ * 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.hive.search.index.store;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.config.IndexStateConfig;
+import org.apache.hive.search.exception.IndexIOException;
+import org.apache.hive.search.index.manifest.IndexManifest;
+
+public final class IndexBackupUtils {
+ private IndexBackupUtils() {}
+
+ /** Push local index state to the backup (remote) location. */
+ public static boolean syncToBackup(IndexStateClient local, IndexStateClient remote)
+ throws IOException {
+ Optional localManifest = local.readManifest();
+ if (localManifest.isEmpty()) {
+ return false;
+ }
+ Optional remoteManifest = remote.readManifest();
+ if (remoteManifest.isPresent()
+ && remoteManifest.get().lastEventId() >= localManifest.get().lastEventId()) {
+ return false;
+ }
+ applyDiff(local, remote, localManifest.get().diff(remoteManifest.orElse(null)));
+ return remote.writeManifest(localManifest.get());
+ }
+
+ /**
+ * If a previous restore left a staging manifest, finalize it when files already match,
+ * or keep it in place so {@link #restoreFromBackup} can resume.
+ */
+ public static void resolveInterruptedRestore(IndexStateClient local)
+ throws IOException {
+ Optional staging = local.readStagingManifest();
+ if (staging.isEmpty()) {
+ local.clearStagingManifest();
+ return;
+ }
+ IndexManifest target = staging.get();
+ if (target.sameFilesAs(local.readLocalFileManifest())) {
+ local.validateRestoredIndex(target);
+ local.clearStagingManifest();
+ }
+ }
+
+ /** Pull backup (remote) index state into the local directory. */
+ public static boolean restoreFromBackup(IndexStateClient local, IndexStateClient remote)
+ throws IOException {
+ Optional remoteManifest = remote.readManifest();
+ if (remoteManifest.isEmpty()) {
+ return false;
+ }
+ IndexManifest target = remoteManifest.get();
+ Optional localManifest = local.readManifest();
+ if (localManifest.isPresent()
+ && localManifest.get().lastEventId() >= target.lastEventId()) {
+ return false;
+ }
+ prepareStagingManifest(local, target);
+ IndexManifest localFiles = local.readLocalFileManifest();
+ applyDiff(remote, local, target.diff(localFiles));
+ local.validateRestoredIndex(target);
+ local.clearStagingManifest();
+ return true;
+ }
+
+ private static void prepareStagingManifest(IndexStateClient local, IndexManifest target)
+ throws IOException {
+ Optional staging = local.readStagingManifest();
+ if (staging.isPresent()
+ && staging.get().lastEventId() == target.lastEventId()
+ && staging.get().modelName().equals(target.modelName())) {
+ return;
+ }
+ local.clearStagingManifest();
+ local.writeStagingManifest(target);
+ }
+
+ private static void applyDiff(IndexStateClient source, IndexStateClient dest,
+ List ops) throws IOException {
+ for (IndexManifest.ChangedFileOp op : ops) {
+ switch (op) {
+ case IndexManifest.ChangedFileOp.Add add -> {
+ try (InputStream in = source.read(add.fileName())) {
+ dest.write(add.fileName(), in);
+ }
+ }
+ case IndexManifest.ChangedFileOp.Del del -> dest.delete(del.fileName());
+ default -> throw new IndexIOException("Unexpected file operation during backup sync: " + op);
+ }
+ }
+ }
+
+ public static IndexStateClient openRemote(String remoteUri, String indexName, Configuration conf)
+ throws IOException {
+ IndexStateConfig.validateRemoteUri(remoteUri);
+ return new RemoteStateClient(URI.create(remoteUri), conf, indexName);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/IndexStateClient.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/IndexStateClient.java
new file mode 100644
index 000000000000..bf69dda471bb
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/IndexStateClient.java
@@ -0,0 +1,65 @@
+/*
+ * 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.hive.search.index.store;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Optional;
+
+import org.apache.hive.search.exception.IndexIOException;
+import org.apache.hive.search.index.manifest.IndexManifest;
+
+public interface IndexStateClient {
+
+ Optional readManifest() throws IOException;
+
+ InputStream read(String fileName) throws IOException;
+
+ void write(String fileName, InputStream stream) throws IOException;
+
+ void delete(String fileName) throws IOException;
+
+ default boolean writeManifest(IndexManifest manifest) throws IOException {
+ write(IndexManifest.MANIFEST_FILE_NAME, new ByteArrayInputStream(manifest.toJsonBytes()));
+ return true;
+ }
+
+ default Optional readStagingManifest() throws IOException {
+ return Optional.empty();
+ }
+
+ default void writeStagingManifest(IndexManifest manifest) throws IOException {
+
+ }
+
+ default void clearStagingManifest() throws IOException {
+
+ }
+
+ /** Snapshot of index files currently on disk, excluding staging metadata */
+ default IndexManifest readLocalFileManifest() throws IOException {
+ return readManifest().orElse(null);
+ }
+
+ default void validateRestoredIndex(IndexManifest expected) throws IndexIOException {
+
+ }
+
+ void clear() throws IOException;
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/LocalStateClient.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/LocalStateClient.java
new file mode 100644
index 000000000000..15801da921e2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/LocalStateClient.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.hive.search.index.store;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.NoSuchFileException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hive.search.exception.IndexIOException;
+import org.apache.hive.search.index.manifest.IndexManifest;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.SegmentInfos;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.IOContext;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.store.IndexOutput;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public record LocalStateClient(Directory directory, String indexName)
+ implements IndexStateClient {
+ private static final Logger LOG = LoggerFactory.getLogger(LocalStateClient.class);
+
+ public static boolean isStagingFile(String fileName) {
+ return IndexManifest.STAGING_MANIFEST_FILE_NAME.equals(fileName);
+ }
+
+ @Override
+ public Optional readManifest() throws IOException {
+ if (hasStagingManifest()) {
+ return Optional.empty();
+ }
+ if (!DirectoryReader.indexExists(directory)) {
+ return Optional.empty();
+ }
+ SegmentInfos segmentInfos = SegmentInfos.readLatestCommit(directory);
+ Map userData = segmentInfos.getUserData();
+ String nid = userData.get("nid");
+ if (StringUtils.isEmpty(nid)) {
+ return Optional.empty();
+ }
+ String modelName = userData.get("model");
+ if (StringUtils.isEmpty(modelName)) {
+ return Optional.empty();
+ }
+ long eventId;
+ try {
+ eventId = Long.parseLong(nid);
+ } catch (NumberFormatException e) {
+ throw new IndexIOException("Invalid commit checkpoint in index metadata: nid=" + nid, e);
+ }
+ List files = new ArrayList<>();
+ for (String file : segmentInfos.files(true)) {
+ files.add(new IndexManifest.IndexFile(file, fileLength(file)));
+ }
+ return Optional.of(IndexManifest.create(indexName, files, modelName, eventId));
+ }
+
+ @Override
+ public Optional readStagingManifest() throws IOException {
+ if (!hasStagingManifest()) {
+ return Optional.empty();
+ }
+ try (IndexInput in = directory.openInput(IndexManifest.STAGING_MANIFEST_FILE_NAME,
+ IOContext.DEFAULT)) {
+ byte[] bytes = new byte[(int) in.length()];
+ in.readBytes(bytes, 0, bytes.length);
+ return Optional.of(IndexManifest.fromJson(bytes));
+ } catch (IOException e) {
+ LOG.warn("Failed to read the staging manifest", e);
+ return Optional.empty();
+ }
+ }
+
+ @Override
+ public void writeStagingManifest(IndexManifest manifest) throws IOException {
+ write(IndexManifest.STAGING_MANIFEST_FILE_NAME,
+ new ByteArrayInputStream(manifest.toJsonBytes()));
+ }
+
+ @Override
+ public void clearStagingManifest() throws IOException {
+ delete(IndexManifest.STAGING_MANIFEST_FILE_NAME);
+ }
+
+ public boolean hasStagingManifest() throws IOException {
+ return Arrays.asList(directory.listAll())
+ .contains(IndexManifest.STAGING_MANIFEST_FILE_NAME);
+ }
+
+ @Override
+ public IndexManifest readLocalFileManifest() throws IOException {
+ List files = new ArrayList<>();
+ for (String file : directory.listAll()) {
+ if (isStagingFile(file)) {
+ continue;
+ }
+ files.add(new IndexManifest.IndexFile(file, fileLength(file)));
+ }
+ return IndexManifest.create(indexName, files, "", -1);
+ }
+
+ public boolean isIndexReadable() throws IOException {
+ return DirectoryReader.indexExists(directory);
+ }
+
+ @Override
+ public void validateRestoredIndex(IndexManifest expected) throws IndexIOException {
+ try {
+ if (!expected.sameFilesAs(readLocalFileManifest())) {
+ throw new IndexIOException("Restored index files do not match staging manifest");
+ }
+ if (!isIndexReadable()) {
+ throw new IndexIOException("Restored index is not readable by Lucene");
+ }
+ SegmentInfos segmentInfos = SegmentInfos.readLatestCommit(directory);
+ Map userData = segmentInfos.getUserData();
+ String nid = userData.get("nid");
+ if (StringUtils.isEmpty(nid)) {
+ throw new IndexIOException("Restored index is missing commit checkpoint");
+ }
+ String modelName = userData.get("model");
+ if (StringUtils.isEmpty(modelName)) {
+ throw new IndexIOException("Restored index is missing embedding model metadata");
+ }
+ long eventId;
+ try {
+ eventId = Long.parseLong(nid);
+ } catch (NumberFormatException e) {
+ throw new IndexIOException("Restored index has invalid commit checkpoint: nid=" + nid, e);
+ }
+ if (eventId != expected.lastEventId()) {
+ throw new IndexIOException(
+ "Restored index checkpoint mismatch: expected nid=" + expected.lastEventId()
+ + " but found nid=" + eventId);
+ }
+ if (!expected.modelName().equals(modelName)) {
+ throw new IndexIOException("Restored index embedding model mismatch");
+ }
+ } catch (IOException e) {
+ throw IndexIOException.wrap(e);
+ }
+ }
+
+ @Override
+ public InputStream read(String fileName) throws IOException {
+ return new DirectoryInputStream(directory, fileName);
+ }
+
+ @Override
+ public void write(String fileName, InputStream stream) throws IOException {
+ if (Arrays.asList(directory.listAll()).contains(fileName)) {
+ directory.deleteFile(fileName);
+ }
+ try (IndexOutput out = directory.createOutput(fileName, IOContext.DEFAULT)) {
+ stream.transferTo(new OutputStreamAdapter(out));
+ }
+ }
+
+ @Override
+ public void delete(String fileName) throws IOException {
+ if (Arrays.asList(directory.listAll()).contains(fileName)) {
+ directory.deleteFile(fileName);
+ }
+ }
+
+ @Override
+ public void clear() throws IOException {
+ for (String file : directory.listAll()) {
+ directory.deleteFile(file);
+ }
+ }
+
+ private long fileLength(String fileName) throws IOException {
+ try (IndexInput input = directory.openInput(fileName, IOContext.DEFAULT)) {
+ return input.length();
+ }
+ }
+
+ private static final class DirectoryInputStream extends InputStream {
+ private final IndexInput input;
+
+ DirectoryInputStream(Directory directory, String fileName) throws IOException {
+ if (!Arrays.asList(directory.listAll()).contains(fileName)) {
+ throw new NoSuchFileException(fileName);
+ }
+ this.input = directory.openInput(fileName, IOContext.DEFAULT);
+ }
+
+ @Override
+ public int read() throws IOException {
+ if (input.getFilePointer() >= input.length()) {
+ return -1;
+ }
+ return Byte.toUnsignedInt(input.readByte());
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ int remaining = (int) Math.min(len, input.length() - input.getFilePointer());
+ if (remaining <= 0) {
+ return -1;
+ }
+ input.readBytes(b, off, remaining);
+ return remaining;
+ }
+
+ @Override
+ public void close() throws IOException {
+ input.close();
+ }
+ }
+
+ private static final class OutputStreamAdapter extends OutputStream {
+ private final IndexOutput output;
+
+ OutputStreamAdapter(IndexOutput output) {
+ this.output = output;
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ output.writeByte((byte) b);
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ output.writeBytes(b, off, len);
+ }
+
+ @Override
+ public void close() throws IOException {
+ output.close();
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/RemoteStateClient.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/RemoteStateClient.java
new file mode 100644
index 000000000000..4067d7e4c5f4
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/index/store/RemoteStateClient.java
@@ -0,0 +1,88 @@
+/*
+ * 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.hive.search.index.store;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.util.Optional;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hive.search.index.manifest.IndexManifest;
+
+/** Remote index backup target backed by Hadoop {@link FileSystem}. */
+public final class RemoteStateClient implements IndexStateClient {
+ private final FileSystem fs;
+ private final Path root;
+
+ public RemoteStateClient(URI baseUri, Configuration conf, String indexName)
+ throws IOException {
+ this.fs = FileSystem.get(baseUri, conf);
+ this.root = resolveRoot(baseUri, indexName);
+ fs.mkdirs(root);
+ }
+
+ private static Path resolveRoot(URI baseUri, String indexName) {
+ return new Path(new Path(baseUri), indexName);
+ }
+
+ @Override
+ public Optional readManifest() throws IOException {
+ Path manifestPath = new Path(root, IndexManifest.MANIFEST_FILE_NAME);
+ if (!fs.exists(manifestPath)) {
+ return Optional.empty();
+ }
+ try (InputStream in = fs.open(manifestPath)) {
+ return Optional.of(IndexManifest.fromJson(in.readAllBytes()));
+ }
+ }
+
+ @Override
+ public InputStream read(String fileName) throws IOException {
+ return fs.open(new Path(root, fileName));
+ }
+
+ @Override
+ public void write(String fileName, InputStream stream) throws IOException {
+ Path target = new Path(root, fileName);
+ Path parent = target.getParent();
+ if (parent != null && !fs.exists(parent)) {
+ fs.mkdirs(parent);
+ }
+ try (OutputStream out = fs.create(target, true)) {
+ stream.transferTo(out);
+ }
+ }
+
+ @Override
+ public void delete(String fileName) throws IOException {
+ Path target = new Path(root, fileName);
+ if (fs.exists(target)) {
+ fs.delete(target, false);
+ }
+ }
+
+ @Override
+ public void clear() throws IOException {
+ fs.delete(root, true);
+ fs.mkdirs(root);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbedModel.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbedModel.java
new file mode 100644
index 000000000000..33ebf31a303a
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbedModel.java
@@ -0,0 +1,39 @@
+/*
+ * 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.hive.search.inference;
+
+import org.apache.hive.search.exception.IndexException;
+
+public interface EmbedModel extends AutoCloseable {
+ enum TaskType {
+ DOCUMENT,
+ QUERY
+ }
+
+ String name();
+
+ float[] embed(TaskType task, String text) throws IndexException;
+
+ default float[][] embedBatch(TaskType task, String[] texts) throws IndexException {
+ float[][] result = new float[texts.length][];
+ for (int i = 0; i < texts.length; i++) {
+ result[i] = embed(task, texts[i]);
+ }
+ return result;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbedModelRegistry.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbedModelRegistry.java
new file mode 100644
index 000000000000..7ad5f2a2f377
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbedModelRegistry.java
@@ -0,0 +1,78 @@
+/*
+ * 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.hive.search.inference;
+
+import java.io.IOException;
+import java.util.Map;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.exception.InitializeException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public record EmbedModelRegistry(Map models, EmbeddingCache embeddingCache)
+ implements AutoCloseable {
+ private static final Logger LOG = LoggerFactory.getLogger(EmbedModelRegistry.class);
+
+ public EmbedModelRegistry(Map models) {
+ this(models, EmbeddingCache.disabled());
+ }
+
+ public EmbedModelRegistry(Map models, EmbeddingCache embeddingCache) {
+ this.models = Map.copyOf(models);
+ this.embeddingCache = embeddingCache;
+ }
+
+ public static EmbedModelRegistry create(Configuration configuration)
+ throws InitializeException, IOException {
+ InferenceConfig inference = new InferenceConfig(configuration);
+ EmbeddingCache embeddingCache = EmbeddingCache.create(configuration);
+ long start = System.currentTimeMillis();
+ InferenceConfig.EmbeddingModelSpec embeddingConfig = inference.embedding();
+ String modelName = inference.modelName();
+ EmbedModel embedModel = new LocalOnnxEmbeddingModel(modelName, embeddingConfig.getModelDir(),
+ embeddingConfig.getPrompt());
+ long warmupStart = System.currentTimeMillis();
+ try {
+ embedModel.embed(EmbedModel.TaskType.QUERY, "warmup");
+ } catch (IndexException e) {
+ throw new InitializeException("Failed to warm up embedding model '" + modelName + "'", e);
+ }
+ LOG.info("Loaded embedding model '{}' from {} in {}ms (warmup {}ms)",
+ modelName, embeddingConfig.getModelDir(), System.currentTimeMillis() - start,
+ System.currentTimeMillis() - warmupStart);
+ return new EmbedModelRegistry(Map.of(modelName, embedModel), embeddingCache);
+ }
+
+ public EmbedModel get(String modelRef) throws IndexException {
+ EmbedModel model = models.get(modelRef);
+ if (model == null) {
+ throw new IndexException("Embedding model '" + modelRef + "' is not configured");
+ }
+ return model;
+ }
+
+ @Override
+ public void close() throws Exception {
+ for (EmbedModel model : models.values()) {
+ model.close();
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbeddingCache.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbeddingCache.java
new file mode 100644
index 000000000000..e3f3757c9693
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbeddingCache.java
@@ -0,0 +1,99 @@
+/*
+ * 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.hive.search.inference;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.config.InferenceConfig;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.hash.Hashing;
+
+/** In-memory LRU cache of document embeddings keyed by model, task, and text hash. */
+public final class EmbeddingCache {
+ private final Cache cache;
+ private final AtomicLong hits = new AtomicLong();
+ private final AtomicLong misses = new AtomicLong();
+
+ private EmbeddingCache(Cache cache) {
+ this.cache = cache;
+ }
+
+ public static EmbeddingCache create(Configuration configuration) {
+ InferenceConfig inference = new InferenceConfig(configuration);
+ if (!inference.isEmbeddingCacheEnabled()) {
+ return disabled();
+ }
+ return new EmbeddingCache(CacheBuilder.newBuilder()
+ .maximumSize(inference.getEmbeddingCacheMaxEntries())
+ .build());
+ }
+
+ public static EmbeddingCache disabled() {
+ return new EmbeddingCache(null);
+ }
+
+ public Optional get(String modelName, EmbedModel.TaskType task, String text) {
+ if (cache == null) {
+ misses.incrementAndGet();
+ return Optional.empty();
+ }
+ CacheKey key = CacheKey.of(modelName, task, text);
+ float[] cached = cache.getIfPresent(key);
+ if (cached == null) {
+ misses.incrementAndGet();
+ return Optional.empty();
+ }
+ hits.incrementAndGet();
+ return Optional.of(Arrays.copyOf(cached, cached.length));
+ }
+
+ public void put(String modelName, EmbedModel.TaskType task, String text, float[] embedding) {
+ if (cache == null) {
+ return;
+ }
+ cache.put(CacheKey.of(modelName, task, text), Arrays.copyOf(embedding, embedding.length));
+ }
+
+ public long hits() {
+ return hits.get();
+ }
+
+ public long misses() {
+ return misses.get();
+ }
+
+ public boolean enabled() {
+ return cache != null;
+ }
+
+ private record CacheKey(String modelName, EmbedModel.TaskType task, long textHash,
+ String text) {
+ static CacheKey of(String modelName, EmbedModel.TaskType task, String text) {
+ long hash = Hashing.murmur3_128()
+ .hashString(text, StandardCharsets.UTF_8)
+ .asLong();
+ return new CacheKey(modelName, task, hash, text);
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbeddingPrompt.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbeddingPrompt.java
new file mode 100644
index 000000000000..41f5689aa2b2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/EmbeddingPrompt.java
@@ -0,0 +1,36 @@
+/*
+ * 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.hive.search.inference;
+
+/** Model-specific text prefixes applied before tokenization. */
+public record EmbeddingPrompt(String documentPrefix, String queryPrefix) {
+ public static EmbeddingPrompt e5() {
+ return new EmbeddingPrompt("passage: ", "query: ");
+ }
+
+ public static EmbeddingPrompt none() {
+ return new EmbeddingPrompt("", "");
+ }
+
+ public String prefixFor(EmbedModel.TaskType task) {
+ return switch (task) {
+ case DOCUMENT -> documentPrefix;
+ case QUERY -> queryPrefix;
+ };
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/LocalOnnxEmbeddingModel.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/LocalOnnxEmbeddingModel.java
new file mode 100644
index 000000000000..d1e4dba9ccfc
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/inference/LocalOnnxEmbeddingModel.java
@@ -0,0 +1,106 @@
+/*
+ * 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.hive.search.inference;
+
+import dev.langchain4j.data.embedding.Embedding;
+import dev.langchain4j.data.segment.TextSegment;
+import dev.langchain4j.model.embedding.onnx.OnnxEmbeddingModel;
+import dev.langchain4j.model.embedding.onnx.PoolingMode;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.exception.IndexException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class LocalOnnxEmbeddingModel implements EmbedModel {
+ private static final Logger LOG = LoggerFactory.getLogger(LocalOnnxEmbeddingModel.class);
+
+ private final String name;
+ private final OnnxEmbeddingModel model;
+ private final EmbeddingPrompt prompt;
+ private final ExecutorService inferExecutor;
+
+ public LocalOnnxEmbeddingModel(String name, Path modelDir, EmbeddingPrompt prompt) {
+ this.name = name;
+ this.prompt = prompt == null ? EmbeddingPrompt.none() : prompt;
+ this.inferExecutor = Executors.newSingleThreadExecutor(r -> {
+ Thread thread = new Thread(r, "EmbedModel-" + name);
+ thread.setDaemon(true);
+ return thread;
+ });
+ this.model = new OnnxEmbeddingModel(
+ modelDir.resolve(InferenceConfig.MODEL_ONNX_FILE),
+ modelDir.resolve(InferenceConfig.TOKENIZER),
+ PoolingMode.MEAN,
+ inferExecutor);
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public float[] embed(TaskType task, String text) throws IndexException {
+ try {
+ return model.embed(prompt.prefixFor(task) + text).content().vector();
+ } catch (RuntimeException e) {
+ throw IndexException.wrap("Failed to encode text with model '" + name + "'", e);
+ }
+ }
+
+ @Override
+ public float[][] embedBatch(TaskType task, String[] texts) throws IndexException {
+ try {
+ String prefix = prompt.prefixFor(task);
+ List segments = new ArrayList<>(texts.length);
+ for (String text : texts) {
+ segments.add(TextSegment.from(prefix + text));
+ }
+ List embeddings = model.embedAll(segments).content();
+ float[][] vectors = new float[texts.length][];
+ for (int i = 0; i < texts.length; i++) {
+ vectors[i] = embeddings.get(i).vector();
+ }
+ return vectors;
+ } catch (RuntimeException e) {
+ throw IndexException.wrap("Failed to encode batch with model '" + name + "'", e);
+ }
+ }
+
+ @Override
+ public void close() {
+ inferExecutor.shutdown();
+ try {
+ if (!inferExecutor.awaitTermination(30, TimeUnit.SECONDS)) {
+ inferExecutor.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ inferExecutor.shutdownNow();
+ }
+ LOG.debug("Closed embedding model '{}'", name);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/FieldSchema.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/FieldSchema.java
new file mode 100644
index 000000000000..065429fa5bc9
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/FieldSchema.java
@@ -0,0 +1,34 @@
+/*
+ * 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.hive.search.mapping;
+
+public interface FieldSchema {
+ String name();
+
+ boolean store();
+
+ boolean filter();
+
+ record TextFieldSchema(
+ String name, SearchParams search, boolean store, boolean filter)
+ implements FieldSchema {
+ public TextFieldSchema(String name, SearchParams search) {
+ this(name, search, true, false);
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/IndexMapping.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/IndexMapping.java
new file mode 100644
index 000000000000..97f803dc9fbe
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/IndexMapping.java
@@ -0,0 +1,83 @@
+/*
+ * 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.hive.search.mapping;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.config.IndexStateConfig;
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.core.KeywordAnalyzer;
+import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper;
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+
+public record IndexMapping(
+ String indexName, Configuration configuration, Map fields) {
+
+ public IndexConfig config() {
+ return new IndexConfig(configuration);
+ }
+
+ public IndexStateConfig store() {
+ return new IndexStateConfig(configuration, indexName);
+ }
+
+ public InferenceConfig inference() {
+ return new InferenceConfig(configuration);
+ }
+
+ public SearchConfig search() {
+ return new SearchConfig(configuration);
+ }
+
+ public FieldSchema fieldSchema(String fieldName) {
+ return fields.get(fieldName);
+ }
+
+ public List hybridFields() {
+ List result = new ArrayList<>();
+ for (Map.Entry entry : fields.entrySet()) {
+ if (entry.getValue() instanceof FieldSchema.TextFieldSchema text && text.search().hybrid()) {
+ result.add(entry.getKey());
+ }
+ }
+ return result;
+ }
+
+ public Optional soleHybridField() {
+ List hybridFields = hybridFields();
+ return hybridFields.size() == 1 ? Optional.of(hybridFields.getFirst()) : Optional.empty();
+ }
+
+ public Analyzer analyzer() {
+ Map fieldAnalyzers = new HashMap<>();
+ for (Map.Entry entry : fields.entrySet()) {
+ if (entry.getValue() instanceof FieldSchema.TextFieldSchema text && text.search().lexical()) {
+ fieldAnalyzers.put(entry.getKey(), new StandardAnalyzer());
+ }
+ }
+ return new PerFieldAnalyzerWrapper(new KeywordAnalyzer(), fieldAnalyzers);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/SearchParams.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/SearchParams.java
new file mode 100644
index 000000000000..89c324320c34
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/SearchParams.java
@@ -0,0 +1,40 @@
+/*
+ * 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.hive.search.mapping;
+
+import org.apache.commons.lang3.StringUtils;
+
+public record SearchParams(boolean lexical, String semanticModel, VectorDistance distance) {
+
+ public static SearchParams disabled() {
+ return new SearchParams(false, null, VectorDistance.COSINE);
+ }
+
+ public boolean semantic() {
+ return StringUtils.isNotEmpty(semanticModel);
+ }
+
+ public boolean hybrid() {
+ return lexical && semantic();
+ }
+
+ public enum VectorDistance {
+ COSINE,
+ DOT
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/TableDocument.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/TableDocument.java
new file mode 100644
index 000000000000..860f60efcc13
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/TableDocument.java
@@ -0,0 +1,117 @@
+/*
+ * 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.hive.search.mapping;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.mapping.field.Field;
+import org.apache.hive.search.mapping.field.IdField;
+import org.apache.hive.search.mapping.field.TextField;
+import org.apache.lucene.document.BinaryDocValuesField;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.KnnFloatVectorField;
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.document.StringField;
+import org.apache.lucene.index.VectorSimilarityFunction;
+import org.apache.lucene.util.BytesRef;
+
+public class TableDocument {
+ private static final int MAX_FIELD_SEARCH_SIZE = 32_768;
+
+ private final List fields;
+ private final Document document;
+ private final IndexMapping indexMapping;
+ private final IdField idField;
+ public static final String FILTER_SUFFIX = ".filter";
+
+ public TableDocument(IdField idf, List otherFields, IndexMapping mapping) {
+ idField = idf;
+ indexMapping = mapping;
+ fields = new ArrayList<>(1 + otherFields.size());
+ fields.add(idField);
+ fields.addAll(otherFields);
+ document = new Document();
+ }
+
+ public void fill(TextField field, FieldSchema.TextFieldSchema schema)
+ throws IndexException {
+ String trimmed = trim(field.value(), MAX_FIELD_SEARCH_SIZE);
+
+ if (schema.filter()) {
+ document.add(
+ new StringField(field.name() + FILTER_SUFFIX, field.value(),
+ org.apache.lucene.document.Field.Store.NO));
+ }
+ if (schema.search().lexical()) {
+ org.apache.lucene.document.Field.Store store =
+ schema.store() ? org.apache.lucene.document.Field.Store.YES
+ : org.apache.lucene.document.Field.Store.NO;
+ document.add(new org.apache.lucene.document.TextField(field.name(), trimmed, store));
+ } else if (schema.store()) {
+ document.add(new StoredField(field.name(), field.value()));
+ }
+ if (schema.search().semantic()) {
+ if (field.embedding() == null) {
+ throw new IndexException("semantic field '" + field.name() + "' requires embedding");
+ }
+ VectorSimilarityFunction similarity = schema.search().distance() == SearchParams.VectorDistance.DOT ?
+ VectorSimilarityFunction.DOT_PRODUCT : VectorSimilarityFunction.COSINE;
+ document.add(new KnnFloatVectorField(field.name(), field.embedding(), similarity));
+ }
+ }
+
+ private static String trim(String value, int max) {
+ return value.length() > max ? value.substring(0, max) : value;
+ }
+
+ public List toDocuments() throws IndexException {
+ String id = idField().value();
+ document.add(new BinaryDocValuesField("_id" + FILTER_SUFFIX, new BytesRef(id)));
+ document.add(new StoredField("_id", id));
+ document.add(new StringField("_id" + FILTER_SUFFIX, id,
+ org.apache.lucene.document.Field.Store.NO));
+ for (Field field : fields) {
+ if (field instanceof IdField) {
+ continue;
+ }
+ FieldSchema schema = indexMapping.fieldSchema(field.name());
+ if (schema == null) {
+ continue;
+ }
+ if (schema instanceof FieldSchema.TextFieldSchema text
+ && field instanceof TextField tf) {
+ fill(tf, text);
+ }
+ }
+ return List.of(document);
+ }
+
+ public void appendField(Field field) {
+ fields.add(field);
+ }
+
+ public IdField idField() {
+ return idField;
+ }
+
+ public List fields() {
+ return fields;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/Field.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/Field.java
new file mode 100644
index 000000000000..35ac9bc2e0de
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/Field.java
@@ -0,0 +1,23 @@
+/*
+ * 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.hive.search.mapping.field;
+
+/** A single field value within a {@link org.apache.hive.search.mapping.TableDocument}. */
+public interface Field {
+ String name();
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/IdField.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/IdField.java
new file mode 100644
index 000000000000..5fdcfcded2c6
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/IdField.java
@@ -0,0 +1,20 @@
+/*
+ * 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.hive.search.mapping.field;
+
+public record IdField(String name, String value) implements Field {}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/TextField.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/TextField.java
new file mode 100644
index 000000000000..3eac83620d5c
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/mapping/field/TextField.java
@@ -0,0 +1,49 @@
+/*
+ * 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.hive.search.mapping.field;
+
+import java.util.Objects;
+
+public record TextField(String name, String value, float[] embedding) implements Field {
+ public TextField(String name, String value) {
+ this(name, value, null);
+ }
+
+ public TextField {
+ if ("_id".equals(name)) {
+ throw new IllegalArgumentException("use IdField for _id, not TextField");
+ }
+ }
+
+ public TextField withEmbedding(float[] newEmbedding) {
+ return new TextField(name, value, newEmbedding);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass())
+ return false;
+ TextField textField = (TextField) o;
+ return Objects.equals(name, textField.name) && Objects.equals(value, textField.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, value);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/BootstrapIndexer.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/BootstrapIndexer.java
new file mode 100644
index 000000000000..304dc0a64bdd
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/BootstrapIndexer.java
@@ -0,0 +1,277 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.RetryingMetaStoreClient;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.index.Indexer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+final class BootstrapIndexer {
+ private static final Logger LOG = LoggerFactory.getLogger(BootstrapIndexer.class);
+ private static final List END_OF_STREAM = List.of();
+ private static final TableBatch END_OF_WORK = new TableBatch(null, List.of());
+
+ private final Configuration configuration;
+ private final IndexConfig indexConfig;
+ private final IndexMapping mapping;
+ private final Indexer indexer;
+ private final IMetaStoreClient client;
+ private final boolean shareFetchClient;
+
+ /** Package-private for unit tests: fetch workers reuse the injected client. */
+ BootstrapIndexer(Configuration configuration,
+ IndexMapping mapping, Indexer indexer,
+ IMetaStoreClient client, boolean shareFetchClient) {
+ this.configuration = configuration;
+ this.indexConfig = new IndexConfig(configuration);
+ this.mapping = mapping;
+ this.indexer = indexer;
+ this.client = client;
+ this.shareFetchClient = shareFetchClient;
+ }
+
+ void run(long notificationId) throws Exception {
+ long start = System.currentTimeMillis();
+ List databases = client.getAllDatabases();
+ BatchPlan plan = planBatches(databases);
+ LOG.info("Bootstrap planned {} table(s) in {} batch(es) across {} database(s)",
+ plan.expectedTableCount(), plan.batches().size(), databases.size());
+
+ int fetchThreads = indexConfig.getBootstrapFetchThreads();
+ BlockingQueue workQueue =
+ new ArrayBlockingQueue<>(Math.max(fetchThreads * 2, 8));
+ BlockingQueue> indexQueue =
+ new ArrayBlockingQueue<>(indexConfig.getBootstrapQueueDepth());
+ AtomicReference failure = new AtomicReference<>();
+ AtomicLong indexedTables = new AtomicLong();
+ AtomicInteger completedBatches = new AtomicInteger();
+ CountDownLatch fetchDone = new CountDownLatch(plan.batches().size());
+
+ Thread indexThread = startIndexConsumer(indexQueue, failure, indexedTables, completedBatches);
+ ExecutorService fetchPool = Executors.newFixedThreadPool(fetchThreads, r -> {
+ Thread thread = new Thread(r, "Index-Bootstrap-Fetch");
+ thread.setDaemon(true);
+ return thread;
+ });
+ try {
+ for (int i = 0; i < fetchThreads; i++) {
+ fetchPool.submit(() -> fetchTableWorker(plan, workQueue, indexQueue, failure, fetchDone));
+ }
+ enqueueBatches(plan.batches(), workQueue, failure, fetchDone);
+ awaitFetchCompletion(fetchDone, failure);
+ indexQueue.put(END_OF_STREAM);
+ indexThread.join();
+ rethrowFailure(failure);
+ validateBootstrapTableCount(plan.expectedTableCount().get());
+ indexer.flush(notificationId, true);
+ indexer.syncBackup();
+ long elapsed = System.currentTimeMillis() - start;
+ LOG.info("Built index for {} tables in {} batch(es) over {}ms",
+ plan.expectedTableCount(), plan.batches().size(), elapsed);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IndexException("Bootstrap indexing interrupted", e);
+ } finally {
+ workQueue.offer(END_OF_WORK);
+ fetchPool.shutdownNow();
+ indexThread.interrupt();
+ indexThread.join(TimeUnit.SECONDS.toMillis(30));
+ }
+ }
+
+ private void enqueueBatches(List batches, BlockingQueue workQueue,
+ AtomicReference failure, CountDownLatch fetchDone) throws InterruptedException {
+ int enqueued = 0;
+ try {
+ for (TableBatch batch : batches) {
+ if (failure.get() != null) {
+ break;
+ }
+ workQueue.put(batch);
+ enqueued++;
+ }
+ } finally {
+ for (int i = enqueued; i < batches.size(); i++) {
+ fetchDone.countDown();
+ }
+ workQueue.put(END_OF_WORK);
+ }
+ }
+
+ private BatchPlan planBatches(List databases) throws Exception {
+ int batchSize = indexConfig.getBootstrapBatchSize();
+ List planned = new ArrayList<>();
+ for (String db : databases) {
+ List tableNames = client.getAllTables(db);
+ for (int idx = 0; idx < tableNames.size(); idx += batchSize) {
+ int end = Math.min(idx + batchSize, tableNames.size());
+ planned.add(new TableBatch(db, List.copyOf(tableNames.subList(idx, end))));
+ }
+ }
+ return new BatchPlan(new AtomicInteger(0), planned);
+ }
+
+ private void fetchTableWorker(BatchPlan plan, BlockingQueue workQueue,
+ BlockingQueue> indexQueue, AtomicReference failure,
+ CountDownLatch fetchDone) {
+ if (shareFetchClient) {
+ runFetchLoop(client, plan, workQueue, indexQueue, failure, fetchDone);
+ return;
+ }
+ try (IMetaStoreClient fetchClient = RetryingMetaStoreClient.getProxy(configuration, true)) {
+ runFetchLoop(fetchClient, plan, workQueue, indexQueue, failure, fetchDone);
+ } catch (Exception e) {
+ recordFailure(failure, e);
+ }
+ }
+
+ private void runFetchLoop(IMetaStoreClient fetchClient, BatchPlan plan,
+ BlockingQueue workQueue, BlockingQueue> indexQueue,
+ AtomicReference failure, CountDownLatch fetchDone) {
+ try {
+ while (true) {
+ TableBatch batch = workQueue.take();
+ if (batch == END_OF_WORK) {
+ workQueue.offer(END_OF_WORK);
+ return;
+ }
+ try {
+ if (failure.get() == null) {
+ List
tables =
+ fetchClient.getTableObjectsByName(batch.database(), batch.tableNames());
+ plan.expectedTableCount().addAndGet(tables.size());
+ List documents = new ArrayList<>(tables.size());
+ for (Table table : tables) {
+ documents.add(MetastoreTableMapper.fromTable(table, mapping));
+ }
+ indexQueue.put(documents);
+ }
+ } catch (Exception e) {
+ recordFailure(failure, e);
+ } finally {
+ fetchDone.countDown();
+ }
+ }
+ } catch (Exception e) {
+ recordFailure(failure, e);
+ }
+ }
+
+ private Thread startIndexConsumer(BlockingQueue> indexQueue,
+ AtomicReference failure, AtomicLong indexedTables,
+ AtomicInteger completedBatches) {
+ Thread indexThread = new Thread(() -> {
+ long lastProgressLog = System.currentTimeMillis();
+ try {
+ while (true) {
+ List documents = indexQueue.take();
+ if (documents == END_OF_STREAM) {
+ break;
+ }
+ if (failure.get() != null) {
+ break;
+ }
+ indexer.addDocuments(documents);
+ long indexed = indexedTables.addAndGet(documents.size());
+ int done = completedBatches.incrementAndGet();
+ indexer.flush(-1000000L, false);
+ long now = System.currentTimeMillis();
+ if (now - lastProgressLog >= indexConfig.getBootstrapProgressIntervalMs()) {
+ LOG.info("Bootstrap progress: indexed {} table(s) in {} batch(es)", indexed, done);
+ lastProgressLog = now;
+ }
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ recordFailure(failure, e);
+ } catch (Exception e) {
+ recordFailure(failure, e);
+ }
+ }, "Index-Bootstrap-Writer");
+ indexThread.setDaemon(true);
+ indexThread.start();
+ return indexThread;
+ }
+
+ private static void awaitFetchCompletion(CountDownLatch fetchDone,
+ AtomicReference failure) throws Exception {
+ while (fetchDone.getCount() > 0) {
+ if (failure.get() != null) {
+ break;
+ }
+ fetchDone.await(1, TimeUnit.SECONDS);
+ }
+ rethrowFailure(failure);
+ }
+
+ private void validateBootstrapTableCount(int expectedTableCount) throws IndexException {
+ int indexed = indexer.writer().getDocStats().numDocs;
+ if (indexed != expectedTableCount) {
+ throw new IndexException(
+ "Bootstrap index doc count mismatch: indexed " + indexed
+ + " documents but metastore has " + expectedTableCount + " tables");
+ }
+ }
+
+ private static void recordFailure(AtomicReference failure, Exception error) {
+ failure.compareAndSet(null, error);
+ LOG.error("Bootstrap indexing failed", error);
+ }
+
+ private static void rethrowFailure(AtomicReference failure) throws Exception {
+ Exception error = failure.get();
+ if (error != null) {
+ if (error instanceof IndexException indexException) {
+ throw indexException;
+ }
+ if (error instanceof IOException ioException) {
+ throw ioException;
+ }
+ if (error instanceof InterruptedException interruptedException) {
+ Thread.currentThread().interrupt();
+ throw interruptedException;
+ }
+ throw new IndexException("Bootstrap indexing failed", error);
+ }
+ }
+
+ private record BatchPlan(AtomicInteger expectedTableCount, List batches) {}
+
+ private record TableBatch(String database, List tableNames) {}
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreCluster.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreCluster.java
new file mode 100644
index 000000000000..686e4c1e784e
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreCluster.java
@@ -0,0 +1,79 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hadoop.hive.metastore.leader.LeaderElection;
+import org.apache.hadoop.hive.metastore.leader.LeaderException;
+import org.apache.hadoop.hive.metastore.leader.LeaseLeaderElection;
+
+@SuppressWarnings("rawtypes, unchecked")
+public class MetastoreCluster implements AutoCloseable {
+ private static final String CLUSTER_ELECTION_METHOD = "metastore.index.election.method";
+ private static final Map> CACHED = new ConcurrentHashMap<>();
+
+ static {
+ try {
+ CACHED.put("default", Pair.of(new LeaseLeaderElection(),
+ new TableName("__INDEX_CATALOG__", "__INDEX_DATABASE__", "__INDEX_TABLE__")));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private final LeaderElection election;
+
+ public MetastoreCluster(Configuration configuration,
+ LeaderElection.LeadershipStateListener... listeners) throws IOException, LeaderException {
+ String methodName = configuration.get(CLUSTER_ELECTION_METHOD, "default");
+ Pair leo = CACHED.get(methodName);
+ if (leo == null) {
+ throw new IOException("No elector configured for " + methodName);
+ }
+ this.election = leo.getKey();
+ if (listeners != null) {
+ Arrays.stream(listeners).forEach(this.election::addStateListener);
+ }
+
+ this.election.tryBeLeader(configuration, leo.getValue());
+ }
+
+ boolean isLeader() {
+ return this.election.isLeader();
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (election != null) {
+ election.close();
+ }
+ }
+
+ public static void injectElection(Configuration configuration,
+ String methodName, LeaderElection election, T mutex) {
+ configuration.set(CLUSTER_ELECTION_METHOD, methodName);
+ CACHED.put(methodName, Pair.of(election, mutex));
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreEventHandler.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreEventHandler.java
new file mode 100644
index 000000000000..d92f93ebad0d
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreEventHandler.java
@@ -0,0 +1,385 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.common.DatabaseName;
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.RetryingMetaStoreClient;
+import org.apache.hadoop.hive.metastore.api.NotificationEvent;
+import org.apache.hadoop.hive.metastore.api.NotificationEventRequest;
+import org.apache.hadoop.hive.metastore.api.NotificationEventResponse;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hadoop.hive.metastore.messaging.AlterTableMessage;
+import org.apache.hadoop.hive.metastore.messaging.CreateTableMessage;
+import org.apache.hadoop.hive.metastore.messaging.MessageBuilder;
+import org.apache.hadoop.hive.metastore.messaging.MessageDeserializer;
+import org.apache.hadoop.hive.metastore.messaging.MessageFactory;
+import org.apache.hive.search.exception.IndexNotHealthyException;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.thrift.TException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Polls HMS notification events, coalesces them into batches, and dispatches index mutations.
+ *
+ *
On repeated batch failure the handler falls back to single-event apply so one poison event
+ * does not block the entire batch forever. If a poison event still cannot be applied, the index
+ * is marked unhealthy and search is blocked until the root cause is fixed.
+ */
+public class MetastoreEventHandler implements AutoCloseable {
+ private static final Logger LOG = LoggerFactory.getLogger(MetastoreEventHandler.class);
+ private final AtomicBoolean stopped = new AtomicBoolean(false);
+ private final List listeners = Collections.synchronizedList(new ArrayList<>());
+ private final IndexConfig indexConfig;
+ private final IMetaStoreClient client;
+ private final MessageDeserializer deserializer;
+ private Thread metaRefresher;
+ private long lastEventId;
+
+ /** Consecutive batch failures at {@link #failedBatchStartId}. */
+ private int consecutiveBatchFailures;
+ private long failedBatchStartId = -1;
+
+ private MetastoreEventHandler(Configuration configuration) throws TException {
+ this(configuration, RetryingMetaStoreClient.getProxy(configuration, true));
+ }
+
+ /** Package-private for unit tests with a stub {@link IMetaStoreClient}. */
+ MetastoreEventHandler(Configuration configuration, IMetaStoreClient client) {
+ Configuration conf = new Configuration(Objects.requireNonNull(configuration));
+ this.indexConfig = new IndexConfig(conf);
+ this.client = Objects.requireNonNull(client);
+ this.deserializer = MessageFactory.getDefaultInstance(conf).getDeserializer();
+ }
+
+ public static MetastoreEventHandler of(Configuration conf, MetastoreEventListener... listeners)
+ throws TException {
+ MetastoreEventHandler catalogService = new MetastoreEventHandler(conf);
+ return catalogService.addListeners(listeners);
+ }
+
+ public MetastoreEventHandler addListeners(MetastoreEventListener... listeners) {
+ return addListeners(Arrays.asList(listeners));
+ }
+
+ public MetastoreEventHandler addListeners(Collection listeners) {
+ this.listeners.addAll(listeners);
+ return this;
+ }
+
+ public MetastoreEventHandler start(long nid) throws Exception {
+ this.lastEventId = nid;
+ final long sleepInterval = indexConfig.getPollNotificationInterval();
+ final int pollMax = indexConfig.getEventPollMax();
+ final long unhealthyBackoffMs = indexConfig.getEventUnhealthyBackoffMs();
+ this.metaRefresher = new Thread(() -> {
+ while (!stopped.get()) {
+ try {
+ int processed = getNextMetastoreEvents(pollMax);
+ if (processed <= 0) {
+ Thread.sleep(sleepInterval);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ } catch (IndexNotHealthyException e) {
+ LOG.error("Incremental index update halted: {}", e.getMessage(), e);
+ notifyListenersStatus(false, e);
+ try {
+ Thread.sleep(unhealthyBackoffMs);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ }
+ });
+ this.metaRefresher.setName("Metastore-Event-Poller");
+ this.metaRefresher.start();
+ return this;
+ }
+
+ @Override
+ public void close() throws Exception {
+ stopped.set(true);
+ if (metaRefresher != null) {
+ metaRefresher.interrupt();
+ metaRefresher.join();
+ }
+ if (client != null) {
+ client.close();
+ }
+ }
+
+ public int getNextMetastoreEvents(int eventCount) throws IndexNotHealthyException {
+ try {
+ NotificationEventRequest request = new NotificationEventRequest(lastEventId);
+ request.setMaxEvents(eventCount);
+ NotificationEventResponse resp = client.getNextNotification(request, true, null);
+ if (resp == null || resp.getEvents() == null || resp.getEvents().isEmpty()) {
+ LOG.debug("No event found since the last event id: {}", lastEventId);
+ return 0;
+ }
+ List events = resp.getEvents();
+ long batchStartId = events.get(0).getEventId();
+
+ MetastoreEventListener.IndexTask task;
+ try {
+ task = buildTask(events);
+ } catch (Exception parseError) {
+ LOG.warn(
+ "Failed to build notification batch starting at event {}; falling back to single-event apply",
+ batchStartId,
+ parseError);
+ int applied = applyEventsIndividually(events);
+ if (applied > 0) {
+ notifyListenersStatus(true);
+ resetBatchFailureState();
+ return applied;
+ }
+ backoffAfterFailure();
+ return 0;
+ }
+ try {
+ notifyListeners(task);
+ lastEventId = task.lastEventId;
+ notifyListenersStatus(true);
+ resetBatchFailureState();
+ return events.size();
+ } catch (Exception batchError) {
+ recordBatchFailure(batchStartId, task, batchError);
+ if (consecutiveBatchFailures >= indexConfig.getEventBatchMaxFailures()) {
+ LOG.warn(
+ "Batch apply failed {} time(s) at event {}; falling back to single-event apply",
+ consecutiveBatchFailures,
+ failedBatchStartId);
+ int applied = applyEventsIndividually(events);
+ if (applied > 0) {
+ notifyListenersStatus(true);
+ resetBatchFailureState();
+ return applied;
+ }
+ }
+ backoffAfterFailure();
+ return 0;
+ }
+ } catch (IndexNotHealthyException e) {
+ throw e;
+ } catch (Exception e) {
+ LOG.warn("Failed to fetch or parse notification events after lastEventId={}", lastEventId, e);
+ backoffAfterFailure();
+ }
+ return 0;
+ }
+
+ private int applyEventsIndividually(List events)
+ throws IndexNotHealthyException {
+ int applied = 0;
+ for (NotificationEvent event : events) {
+ if (lastEventId >= event.getEventId()) {
+ continue;
+ }
+ MetastoreEventListener.IndexTask task =
+ new MetastoreEventListener.IndexTask();
+ task.firstEventId = event.getEventId();
+ try {
+ dispatchEvent(event, task);
+ task.lastEventId = event.getEventId();
+ notifyListeners(task);
+ lastEventId = event.getEventId();
+ applied++;
+ } catch (Exception e) {
+ long poisonEventId = event.getEventId();
+ String poisonEventType = event.getEventType();
+ if (indexConfig.isEventSkipPoison()) {
+ LOG.error(
+ "Skipping poison notification event {} (type={}); index may be stale for this object",
+ poisonEventId,
+ poisonEventType,
+ e);
+ lastEventId = event.getEventId();
+ applied++;
+ } else {
+ LOG.error(
+ "Stuck on notification event {} (type={}); committed lastEventId={}",
+ poisonEventId,
+ poisonEventType,
+ lastEventId,
+ e);
+ String progress = applied > 0
+ ? applied + " prior event(s) in batch were applied; "
+ : "";
+ throw new IndexNotHealthyException(
+ "Cannot apply notification event "
+ + poisonEventId
+ + " (type="
+ + poisonEventType
+ + "); "
+ + progress
+ + "committed lastEventId="
+ + lastEventId
+ + ". Fix root cause, set "
+ + IndexConfig.EVENT_SKIP_POISON
+ + "=true to skip, or rebuild the index.",
+ e);
+ }
+ }
+ }
+ if (applied > 0) {
+ LOG.info("Applied {} notification event(s) individually; lastEventId={}", applied, lastEventId);
+ }
+ return applied;
+ }
+
+ private MetastoreEventListener.IndexTask buildTask(List events)
+ throws Exception {
+ MetastoreEventListener.IndexTask task = new MetastoreEventListener.IndexTask();
+ for (int i = 0; i < events.size(); i++) {
+ NotificationEvent event = events.get(i);
+ if (lastEventId >= event.getEventId()) {
+ throw new IllegalStateException(
+ "Out-of-order metastore notification event: lastEventId=" + lastEventId
+ + ", eventId=" + event.getEventId());
+ }
+ if (i == 0) {
+ task.firstEventId = event.getEventId();
+ }
+ dispatchEvent(event, task);
+ task.lastEventId = event.getEventId();
+ }
+ return task;
+ }
+
+ private void notifyListeners(MetastoreEventListener.IndexTask task) throws Exception {
+ for (MetastoreEventListener listener : listeners) {
+ listener.notifyIndexTask(task);
+ }
+ }
+
+ private void notifyListenersStatus(boolean healthy, IndexNotHealthyException... errors) {
+ for (MetastoreEventListener listener : listeners) {
+ listener.notifyIndexState(healthy, errors);
+ }
+ }
+
+ private void recordBatchFailure(
+ long batchStartId, MetastoreEventListener.IndexTask task, Exception e) {
+ if (failedBatchStartId != batchStartId) {
+ failedBatchStartId = batchStartId;
+ consecutiveBatchFailures = 1;
+ } else {
+ consecutiveBatchFailures++;
+ }
+ LOG.warn(
+ "Failed to apply notification batch (attempt {}/{}, committed lastEventId={}, "
+ + "batch event range {}-{}, upserts={}, tableDrops={}, dbDrops={})",
+ consecutiveBatchFailures,
+ indexConfig.getEventBatchMaxFailures(),
+ lastEventId,
+ task.firstEventId,
+ task.lastEventId,
+ task.tablesToAdd.size(),
+ task.tablesToDrop.size(),
+ task.databasesToDrop.size(),
+ e);
+ }
+
+ private void resetBatchFailureState() {
+ consecutiveBatchFailures = 0;
+ failedBatchStartId = -1;
+ }
+
+ private void backoffAfterFailure() {
+ long backoffMs = indexConfig.getEventFailureBackoffMs();
+ if (backoffMs <= 0) {
+ return;
+ }
+ try {
+ Thread.sleep(backoffMs);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private void dispatchEvent(
+ NotificationEvent event,
+ MetastoreEventListener.IndexTask task)
+ throws Exception {
+ String message = event.getMessage();
+ switch (event.getEventType()) {
+ case MessageBuilder.CREATE_TABLE_EVENT -> {
+ CreateTableMessage createTableMessage = deserializer.getCreateTableMessage(message);
+ Table table = createTableMessage.getTableObj();
+ TableName tableName = new TableName(table.getCatName(), table.getDbName(), table.getTableName());
+ task.tablesToAdd.put(tableName, table);
+ }
+ case MessageBuilder.DROP_TABLE_EVENT -> {
+ deserializer.getDropTableMessage(message);
+ TableName tableName = new TableName(event.getCatName(), event.getDbName(), event.getTableName());
+ if (task.tablesToAdd.containsKey(tableName)) {
+ task.tablesToAdd.remove(tableName);
+ } else {
+ task.tablesToDrop.add(tableName);
+ }
+ }
+ case MessageBuilder.DROP_DATABASE_EVENT -> {
+ DatabaseName databaseName = new DatabaseName(event.getCatName(), event.getDbName());
+ task.databasesToDrop.add(databaseName);
+ for (TableName tableName : new ArrayList<>(task.tablesToAdd.keySet())) {
+ if (databaseName.equals(new DatabaseName(tableName.getCat(), tableName.getDb()))) {
+ task.tablesToAdd.remove(tableName);
+ }
+ }
+ task.tablesToDrop.removeIf(
+ tableName -> databaseName.equals(new DatabaseName(tableName.getCat(), tableName.getDb())));
+ }
+ case MessageBuilder.ALTER_TABLE_EVENT -> {
+ AlterTableMessage alterTableMessage = deserializer.getAlterTableMessage(message);
+ Table tableAfter = alterTableMessage.getTableObjAfter();
+ Table tableBefore = alterTableMessage.getTableObjBefore();
+ if (!MetastoreTableMapper.hasIndexedFieldsChanged(tableBefore, tableAfter)) {
+ break;
+ }
+ TableName tblNameBefore = new TableName(tableBefore.getCatName(),
+ tableBefore.getDbName(), tableBefore.getTableName());
+ TableName tblNameAfter = new TableName(tableAfter.getCatName(),
+ tableAfter.getDbName(), tableAfter.getTableName());
+ if (task.tablesToAdd.containsKey(tblNameBefore)) {
+ task.tablesToAdd.remove(tblNameBefore);
+ } else {
+ task.tablesToDrop.add(tblNameBefore);
+ }
+ task.tablesToAdd.put(tblNameAfter, tableAfter);
+ }
+ default -> {
+ // Ignored event types still advance the notification cursor when applied individually.
+ }
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreEventListener.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreEventListener.java
new file mode 100644
index 000000000000..dcc8a04787a2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreEventListener.java
@@ -0,0 +1,52 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.hadoop.hive.common.DatabaseName;
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.exception.IndexNotHealthyException;
+
+public interface MetastoreEventListener {
+
+ /**
+ * Apply a coalesced batch of index mutations. Implementations must either complete all
+ * mutations or throw; partial success must remain safe to retry idempotently.
+ */
+ default void notifyIndexTask(IndexTask task) throws IndexException, java.io.IOException {
+
+ }
+
+ default void notifyIndexState(boolean healthy, IndexNotHealthyException... e) {
+
+ }
+
+ class IndexTask {
+ public long firstEventId;
+ public long lastEventId;
+ public Set tablesToDrop = new LinkedHashSet<>();
+ public Set databasesToDrop = new LinkedHashSet<>();
+ public Map tablesToAdd = new LinkedHashMap<>();
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreIndexer.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreIndexer.java
new file mode 100644
index 000000000000..3cb7d317a1d3
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreIndexer.java
@@ -0,0 +1,355 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.common.DatabaseName;
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.RetryingMetaStoreClient;
+import org.apache.hadoop.hive.metastore.api.NotificationEventRequest;
+import org.apache.hadoop.hive.metastore.leader.LeaderElection;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.exception.IndexNotHealthyException;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.index.Indexer;
+import org.apache.hive.search.index.IndexManager;
+import org.apache.hive.search.index.manifest.IndexManifest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class MetastoreIndexer implements AutoCloseable {
+ private static final Logger LOG = LoggerFactory.getLogger(MetastoreIndexer.class);
+
+ private final IMetaStoreClient client;
+ private final long lastEventId;
+ private final Indexer indexer;
+ private final MetastoreCluster cluster;
+ private final MetastoreEventHandler handler;
+ private final IndexManager indexManager;
+ private final FlushIndexListener flushIndexListener;
+ private final boolean shareBootstrapFetchClient;
+
+ public MetastoreIndexer(Configuration configuration, IndexManager indexManager, Indexer indexer)
+ throws Exception {
+ this(configuration, indexManager, indexer,
+ RetryingMetaStoreClient.getProxy(configuration, true), false);
+ }
+
+ MetastoreIndexer(Configuration configuration, IndexManager indexManager, Indexer indexer,
+ IMetaStoreClient client) throws Exception {
+ this(configuration, indexManager, indexer, client, true);
+ }
+
+ MetastoreIndexer(Configuration configuration, IndexManager indexManager, Indexer indexer,
+ IMetaStoreClient client, boolean shareBootstrapFetchClient) throws Exception {
+ this.client = client;
+ this.shareBootstrapFetchClient = shareBootstrapFetchClient;
+ this.indexer = indexer;
+ this.indexManager = indexManager;
+ this.flushIndexListener = new FlushIndexListener(configuration);
+ this.handler = new MetastoreEventHandler(configuration, client);
+ this.handler.addListeners(flushIndexListener, indexManager);
+ this.cluster = new MetastoreCluster(configuration, flushIndexListener);
+ this.lastEventId = initialize();
+ }
+
+ private boolean rebuildIndex() throws Exception {
+ indexManager.resolveInterruptedRestore();
+ if (isIndexValid(indexManager.readLocalManifest().orElse(null))) {
+ return false;
+ }
+ if (isIndexValid(indexManager.readRemoteManifest().orElse(null))) {
+ if (indexManager.restoreBackup()) {
+ return false;
+ }
+ LOG.warn("Failed to restore index from remote backup; clearing partial local index");
+ indexManager.clearLocalIndex();
+ return true;
+ }
+ indexManager.clearLocalIndex();
+ return true;
+ }
+
+ private boolean isIndexValid(IndexManifest indexManifest)
+ throws Exception {
+ if (indexManifest == null) {
+ return false;
+ }
+ String currentModelName = indexManager.mapping().inference().modelName();
+ String modelName = indexManifest.modelName();
+ if (!currentModelName.equals(modelName)) {
+ return false;
+ }
+ long notificationId = indexManifest.lastEventId();
+ return canCatchUp(notificationId);
+ }
+
+ private boolean canCatchUp(long notificationId) throws Exception {
+ if (notificationId < 0) {
+ return false;
+ }
+ try {
+ NotificationEventRequest request = new NotificationEventRequest(notificationId);
+ request.setMaxEvents(10);
+ client.getNextNotification(request, false, null);
+ return true;
+ } catch (IllegalStateException ignored) {
+ LOG.debug("Current index lags too behind, will rebuild");
+ }
+ return false;
+ }
+
+ private long initialize() throws Exception {
+ boolean rebuild = rebuildIndex();
+ if (rebuild && !cluster.isLeader()) {
+ long notificationId = waitForRemoteBackup();
+ indexer.initialize();
+ if (notificationId >= 0) {
+ return notificationId;
+ }
+ return rebuildLeaderIndex();
+ }
+ indexer.initialize();
+ if (rebuild) {
+ return rebuildLeaderIndex();
+ }
+ return indexManager.readLocalManifest().get().lastEventId();
+ }
+
+ private long rebuildLeaderIndex() throws Exception {
+ indexManager.clearRemoteIndex();
+ long notificationId = client.getCurrentNotificationEventId().getEventId();
+ new BootstrapIndexer(
+ indexManager.mapping().configuration(),
+ indexManager.mapping(),
+ indexer,
+ client,
+ shareBootstrapFetchClient).run(notificationId);
+ return notificationId;
+ }
+
+ public void start() throws Exception {
+ handler.start(lastEventId);
+ flushIndexListener.start(lastEventId);
+ }
+
+ /** Package-private for integration tests. */
+ int pollEvents(int count) throws IndexNotHealthyException {
+ return handler.getNextMetastoreEvents(count);
+ }
+
+ /** Package-private for integration tests. */
+ void flushCheckpoint() throws IOException {
+ try {
+ indexer.flush(client.getCurrentNotificationEventId().getEventId(), true);
+ } catch (Exception e) {
+ if (e instanceof IOException ioException) {
+ throw ioException;
+ }
+ throw new IOException("Failed to flush index checkpoint", e);
+ }
+ }
+
+ /** Package-private for integration tests. */
+ void syncBackup() throws IOException {
+ indexer.syncBackup();
+ }
+
+ /**
+ * Wait for a follower-ready remote backup and restore it before the IndexWriter is opened.
+ *
+ * @return restored notification id, or -1 if this instance became leader while waiting
+ */
+ private long waitForRemoteBackup() throws Exception {
+ String currentModelName = indexManager.mapping().inference().modelName();
+ for (; !cluster.isLeader(); Thread.sleep(3000)) {
+ Optional indexManifest = indexManager.readRemoteManifest();
+ if (indexManifest.isEmpty()) {
+ continue;
+ }
+ IndexManifest manifest = indexManifest.get();
+ if (!currentModelName.equals(manifest.modelName())) {
+ LOG.debug("Remote index model {} does not match configured model {}, waiting",
+ manifest.modelName(), currentModelName);
+ continue;
+ }
+ long notificationId = manifest.lastEventId();
+ if (notificationId > 0 && canCatchUp(notificationId)) {
+ if (indexManager.restoreBackup()) {
+ return notificationId;
+ }
+ LOG.warn("Failed to restore the index from remote directory, will retry");
+ }
+ }
+ indexManager.clearLocalIndex();
+ return -1;
+ }
+
+ @Override
+ public void close() throws Exception {
+ try {
+ cluster.close();
+ handler.close();
+ } finally {
+ flushIndexListener.close();
+ client.close();
+ }
+ }
+
+ private class FlushIndexListener
+ implements MetastoreEventListener,
+ LeaderElection.LeadershipStateListener,
+ AutoCloseable {
+ private volatile long eventId;
+ private volatile long lastCommittedEventId;
+ private volatile boolean isLeader;
+ private volatile Thread replicateThread;
+ private volatile boolean started = false;
+ private final Thread commitThread;
+ private final IndexConfig indexConfig;
+
+ public FlushIndexListener(Configuration configuration) {
+ this.indexConfig = new IndexConfig(configuration);
+ this.commitThread = getIndexCommitThread();
+ }
+
+ public void start(long initialEventId) {
+ this.eventId = initialEventId;
+ this.lastCommittedEventId = initialEventId;
+ this.commitThread.start();
+ this.started = true;
+ }
+
+ private Thread getIndexCommitThread() {
+ Thread commitThread = new Thread(() -> {
+ while (!Thread.currentThread().isInterrupted()) {
+ try {
+ Thread.sleep(indexConfig.getFlushInterval());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ if (eventId <= lastCommittedEventId) {
+ continue;
+ }
+ try {
+ if (indexer.flush(eventId, false)) {
+ lastCommittedEventId = eventId;
+ } else if (eventId - lastCommittedEventId > indexConfig.getForceFlushEventGap()) {
+ // Many events processed with no Lucene changes; advance checkpoint metadata.
+ if (indexer.flush(eventId, true)) {
+ lastCommittedEventId = eventId;
+ }
+ }
+ } catch (IOException e) {
+ LOG.warn("Error flushing the index", e);
+ }
+ }
+ });
+ commitThread.setName("Index-Commit");
+ commitThread.setDaemon(true);
+ return commitThread;
+ }
+
+ private Thread getIndexReplicateThread() {
+ Thread replicateThread = new Thread(() -> {
+ while (isLeader && !Thread.currentThread().isInterrupted()) {
+ long interval = 10000;
+ if (started) {
+ try {
+ indexer.syncBackup();
+ interval = indexConfig.getSyncInterval();
+ } catch (IOException e) {
+ LOG.warn("Error replicating the index to remote directory", e);
+ }
+ }
+ try {
+ Thread.sleep(interval);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ });
+ replicateThread.setDaemon(true);
+ replicateThread.setName("Index-Replica");
+ return replicateThread;
+ }
+
+ @Override
+ public void takeLeadership(LeaderElection leaderElection) {
+ isLeader = true;
+ if (indexManager.hasBackup()) {
+ if (replicateThread != null && replicateThread.isAlive()) {
+ return;
+ }
+ replicateThread = getIndexReplicateThread();
+ replicateThread.start();
+ }
+ }
+
+ @Override
+ public void lossLeadership(LeaderElection leaderElection) {
+ isLeader = false;
+ if (replicateThread != null) {
+ replicateThread.interrupt();
+ replicateThread = null;
+ }
+ }
+
+ @Override
+ public void notifyIndexTask(IndexTask task) throws IndexException, IOException {
+ if (!task.databasesToDrop.isEmpty()) {
+ indexer.deleteDatabases(task.databasesToDrop.toArray(new DatabaseName[0]));
+ }
+ if (!task.tablesToDrop.isEmpty()) {
+ String[] docIds = task.tablesToDrop.stream()
+ .map(MetastoreTableMapper::tableId).toList().toArray(new String[0]);
+ indexer.delete(docIds);
+ }
+ if (!task.tablesToAdd.isEmpty()) {
+ List newDocs = task.tablesToAdd.values().stream()
+ .map(t -> MetastoreTableMapper.fromTable(t, indexManager.mapping())).toList();
+ indexer.addDocuments(newDocs);
+ }
+ eventId = task.lastEventId;
+ }
+
+ @Override
+ public void close() throws Exception {
+ isLeader = false;
+ try {
+ if (replicateThread != null) {
+ replicateThread.interrupt();
+ replicateThread = null;
+ }
+ commitThread.interrupt();
+ } finally {
+ if (eventId > lastCommittedEventId) {
+ indexer.flush(eventId, true);
+ lastCommittedEventId = eventId;
+ }
+ }
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreSchemas.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreSchemas.java
new file mode 100644
index 000000000000..040795309b6e
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreSchemas.java
@@ -0,0 +1,79 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.mapping.FieldSchema;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.SearchParams;
+
+public final class MetastoreSchemas {
+
+ private MetastoreSchemas() {}
+
+ public static IndexMapping defaultHiveTablesMapping(String indexName,
+ String semanticModel, Configuration conf) {
+ Map fields = new LinkedHashMap<>();
+ fields.put(MetastoreTableMapper.FIELD_DB, filterText(MetastoreTableMapper.FIELD_DB));
+ fields.put(MetastoreTableMapper.FIELD_TABLE, tableNameText(MetastoreTableMapper.FIELD_TABLE));
+ fields.put(MetastoreTableMapper.FIELD_OWNER, filterText(MetastoreTableMapper.FIELD_OWNER));
+ fields.put(MetastoreTableMapper.FIELD_TABLE_TYPE, filterText(MetastoreTableMapper.FIELD_TABLE_TYPE));
+ fields.put(MetastoreTableMapper.FIELD_LOCATION, storedText(MetastoreTableMapper.FIELD_LOCATION));
+ fields.put(MetastoreTableMapper.FIELD_COMMENT, lexicalText(MetastoreTableMapper.FIELD_COMMENT));
+ fields.put(MetastoreTableMapper.FIELD_COLUMNS, storedText(MetastoreTableMapper.FIELD_COLUMNS));
+ fields.put(
+ MetastoreTableMapper.FIELD_COLUMN_NAMES,
+ lexicalText(MetastoreTableMapper.FIELD_COLUMN_NAMES));
+ fields.put(
+ MetastoreTableMapper.FIELD_COLUMN_COMMENTS,
+ lexicalText(MetastoreTableMapper.FIELD_COLUMN_COMMENTS));
+ fields.put(
+ MetastoreTableMapper.FIELD_SEARCH_TEXT,
+ hybridText(MetastoreTableMapper.FIELD_SEARCH_TEXT, semanticModel));
+ return new IndexMapping(indexName, conf, fields);
+ }
+
+ private static FieldSchema.TextFieldSchema filterText(String name) {
+ return new FieldSchema.TextFieldSchema(name, SearchParams.disabled(), true, true);
+ }
+
+ private static FieldSchema.TextFieldSchema storedText(String name) {
+ return new FieldSchema.TextFieldSchema(name, SearchParams.disabled(), true, false);
+ }
+
+ private static FieldSchema.TextFieldSchema lexicalText(String name) {
+ return new FieldSchema.TextFieldSchema(
+ name, new SearchParams(true, null, SearchParams.VectorDistance.COSINE), true, false);
+ }
+
+ private static FieldSchema.TextFieldSchema tableNameText(String name) {
+ return new FieldSchema.TextFieldSchema(
+ name, new SearchParams(true, null, SearchParams.VectorDistance.COSINE), true, true);
+ }
+
+ private static FieldSchema.TextFieldSchema hybridText(String name, String semanticModel) {
+ return new FieldSchema.TextFieldSchema(
+ name,
+ new SearchParams(true, semanticModel, SearchParams.VectorDistance.COSINE),
+ false,
+ false);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreTableMapper.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreTableMapper.java
new file mode 100644
index 000000000000..e14c41cec6a3
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/metastore/MetastoreTableMapper.java
@@ -0,0 +1,223 @@
+/*
+ * 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.hive.search.metastore;
+
+import java.util.ArrayList;
+import java.util.Locale;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hadoop.hive.metastore.api.FieldSchema;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.mapping.field.Field;
+import org.apache.hive.search.mapping.field.IdField;
+import org.apache.hive.search.mapping.field.TextField;
+
+public final class MetastoreTableMapper {
+ public static final String FIELD_DB = "db";
+ public static final String FIELD_TABLE = "table_name";
+ public static final String FIELD_OWNER = "owner";
+ public static final String FIELD_TABLE_TYPE = "table_type";
+ public static final String FIELD_LOCATION = "location";
+ public static final String FIELD_COMMENT = "comment";
+ public static final String FIELD_COLUMNS = "columns";
+ public static final String FIELD_COLUMN_NAMES = "column_names";
+ public static final String FIELD_COLUMN_COMMENTS = "column_comments";
+ public static final String FIELD_SEARCH_TEXT = "search_text";
+ /** Relative boosts for {@code table_keyword} ranking: table name > column name > comment. */
+ public static final float KEYWORD_BOOST_TABLE_NAME = 3.0f;
+ public static final float KEYWORD_BOOST_COLUMN_NAME = 2.0f;
+ public static final float KEYWORD_BOOST_COMMENT = 1.0f;
+ /** Lexical fields used for table keyword search, highest boost first. */
+ public static final List KEYWORD_SEARCH_FIELDS = List.of(
+ new KeywordSearchField(FIELD_TABLE, KEYWORD_BOOST_TABLE_NAME),
+ new KeywordSearchField(FIELD_COLUMN_NAMES, KEYWORD_BOOST_COLUMN_NAME),
+ new KeywordSearchField(FIELD_COMMENT, KEYWORD_BOOST_COMMENT),
+ new KeywordSearchField(FIELD_COLUMN_COMMENTS, KEYWORD_BOOST_COMMENT));
+ /** Max commented data columns included in search-oriented column fields. */
+ static final int MAX_SEARCH_COLUMNS = 100;
+
+ public record KeywordSearchField(String field, float boost) {}
+
+ private MetastoreTableMapper() {}
+
+ public static String tableId(String catalog, String db, String table) {
+ return catalog + "." + db + "." + table;
+ }
+
+ public static String tableId(TableName tableName) {
+ return tableId(tableName.getCat(), tableName.getDb(), tableName.getTable());
+ }
+
+ public static TableDocument fromTable(Table table, IndexMapping indexMapping) {
+ String db = table.getDbName();
+ String name = table.getTableName();
+ String catalog = table.getCatName();
+ String id = tableId(catalog, db, name);
+ String owner = nullToEmpty(table.getOwner());
+ String tableType = nullToEmpty(table.getTableType());
+ String location = tableLocation(table);
+ String comment = tableComment(table);
+ String columns = formatColumnsForStorage(table);
+ String columnNames = formatColumnNamesForSearch(table);
+ String columnComments = formatColumnCommentsForSearch(table);
+ String searchText = buildSearchText(name, comment, formatColumnsForSearch(table));
+
+ List fields = new ArrayList<>(10);
+ fields.add(new TextField(FIELD_DB, db));
+ fields.add(new TextField(FIELD_TABLE, name.toLowerCase(Locale.ROOT)));
+ fields.add(new TextField(FIELD_OWNER, owner));
+ fields.add(new TextField(FIELD_TABLE_TYPE, tableType));
+ fields.add(new TextField(FIELD_LOCATION, location));
+ fields.add(new TextField(FIELD_COMMENT, comment));
+ fields.add(new TextField(FIELD_COLUMNS, columns));
+ fields.add(new TextField(FIELD_COLUMN_NAMES, columnNames));
+ fields.add(new TextField(FIELD_COLUMN_COMMENTS, columnComments));
+ fields.add(new TextField(FIELD_SEARCH_TEXT, searchText));
+ return new TableDocument(new IdField("_id", id), fields, indexMapping);
+ }
+
+ /** Returns true when an alter would change any value written to the search index. */
+ public static boolean hasIndexedFieldsChanged(Table before, Table after) {
+ Objects.requireNonNull(before);
+ Objects.requireNonNull(after);
+ if (!Objects.equals(before.getCatName(), after.getCatName())
+ || !Objects.equals(before.getDbName(), after.getDbName())
+ || !Objects.equals(before.getTableName(), after.getTableName())) {
+ return true;
+ }
+ return !nullToEmpty(before.getOwner()).equals(nullToEmpty(after.getOwner()))
+ || !nullToEmpty(before.getTableType()).equals(nullToEmpty(after.getTableType()))
+ || !tableLocation(before).equals(tableLocation(after))
+ || !tableComment(before).equals(tableComment(after))
+ || !formatColumnsForStorage(before).equals(formatColumnsForStorage(after))
+ || !formatColumnNamesForSearch(before).equals(formatColumnNamesForSearch(after))
+ || !formatColumnCommentsForSearch(before).equals(formatColumnCommentsForSearch(after));
+ }
+
+ private static String tableLocation(Table table) {
+ return table.getSd() == null ? "" : nullToEmpty(table.getSd().getLocation());
+ }
+
+ private static String tableComment(Table table) {
+ if (table.getParameters() == null || table.getParameters().get("comment") == null) {
+ return "";
+ }
+ return nullToEmpty(table.getParameters().get("comment"));
+ }
+
+ private static String buildSearchText(String tableName, String comment, String searchColumns) {
+ String normalizedTableName = tableName.toLowerCase(Locale.ROOT);
+ if (comment.isEmpty()) {
+ if (searchColumns.isEmpty()) {
+ return normalizedTableName;
+ }
+ return normalizedTableName + " " + searchColumns;
+ }
+ if (searchColumns.isEmpty()) {
+ return normalizedTableName + " " + comment;
+ }
+ return normalizedTableName + " " + comment + " " + searchColumns;
+ }
+
+ private static String formatColumnsForStorage(Table table) {
+ if (table.getSd() == null || table.getSd().getCols() == null) {
+ return "";
+ }
+ List parts = new ArrayList<>(table.getSd().getCols().size());
+ for (FieldSchema column : table.getSd().getCols()) {
+ parts.add(formatColumnForStorage(column));
+ }
+ return String.join("; ", parts);
+ }
+
+ private static String formatColumnNamesForSearch(Table table) {
+ if (table.getSd() == null || table.getSd().getCols() == null) {
+ return "";
+ }
+ List names = new ArrayList<>(table.getSd().getCols().size());
+ for (FieldSchema column : table.getSd().getCols()) {
+ names.add(column.getName().toLowerCase(Locale.ROOT));
+ }
+ return String.join(" ", names);
+ }
+
+ private static String formatColumnCommentsForSearch(Table table) {
+ if (table.getSd() == null || table.getSd().getCols() == null) {
+ return "";
+ }
+ List commented = new ArrayList<>();
+ for (FieldSchema column : table.getSd().getCols()) {
+ if (StringUtils.isNotEmpty(column.getComment())) {
+ commented.add(column);
+ }
+ }
+ int limit = Math.min(commented.size(), MAX_SEARCH_COLUMNS);
+ List parts = new ArrayList<>(limit);
+ for (int i = 0; i < limit; i++) {
+ parts.add(commented.get(i).getComment());
+ }
+ String formatted = String.join("; ", parts);
+ if (commented.size() > MAX_SEARCH_COLUMNS) {
+ return formatted + "; ... (+" + (commented.size() - MAX_SEARCH_COLUMNS) + " more)";
+ }
+ return formatted;
+ }
+
+ private static String formatColumnsForSearch(Table table) {
+ if (table.getSd() == null || table.getSd().getCols() == null) {
+ return "";
+ }
+ List commented = new ArrayList<>();
+ for (FieldSchema column : table.getSd().getCols()) {
+ if (StringUtils.isNotEmpty(column.getComment())) {
+ commented.add(column);
+ }
+ }
+ int limit = Math.min(commented.size(), MAX_SEARCH_COLUMNS);
+ List parts = new ArrayList<>(limit);
+ for (int i = 0; i < limit; i++) {
+ parts.add(formatColumnForSearch(commented.get(i)));
+ }
+ String formatted = String.join("; ", parts);
+ if (commented.size() > MAX_SEARCH_COLUMNS) {
+ return formatted + "; ... (+" + (commented.size() - MAX_SEARCH_COLUMNS) + " more)";
+ }
+ return formatted;
+ }
+
+ private static String formatColumnForStorage(FieldSchema column) {
+ String base = column.getName() + " " + column.getType();
+ if (StringUtils.isNotEmpty(column.getComment())) {
+ return base + " " + column.getComment();
+ }
+ return base;
+ }
+
+ private static String formatColumnForSearch(FieldSchema column) {
+ return column.getName() + " " + column.getComment();
+ }
+
+ private static String nullToEmpty(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/HybridSearch.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/HybridSearch.java
new file mode 100644
index 000000000000..33f1f4d8e468
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/HybridSearch.java
@@ -0,0 +1,92 @@
+/*
+ * 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.hive.search.search;
+
+import java.util.List;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.hive.search.exception.SearchException;
+import org.apache.hive.search.mapping.FieldSchema;
+import org.apache.hive.search.mapping.IndexMapping;
+
+public final class HybridSearch {
+ private HybridSearch() {}
+
+ public record ResolvedHybridQuery(String field, String queryText, Float semanticWeight) {}
+
+ public static ResolvedHybridQuery resolve(SearchArgs.Hybrid args, IndexMapping mapping)
+ throws SearchException {
+ String field = requireHybridField(mapping, args.field());
+ Float semanticWeight = resolveSemanticWeight(args.matchWeight(), args.semanticWeight());
+ return new ResolvedHybridQuery(field, args.queryText(), semanticWeight);
+ }
+
+ public static SearchInternal.FusionRequest toFusionRequest(
+ ResolvedHybridQuery hybrid, int defaultSize, SearchConfig searchConfig)
+ throws SearchException {
+ float semanticWeight = hybrid.semanticWeight() != null ?
+ hybrid.semanticWeight() : searchConfig.getHybridSemanticWeight();
+ float matchWeight = 1.0f - semanticWeight;
+ List retrievers =
+ List.of(
+ new SearchInternal.RetrieverSpec(
+ new SearchArgs.Match(hybrid.queryText()), matchWeight, SearchQuery.Mode.MATCH.name()),
+ new SearchInternal.RetrieverSpec(
+ new SearchArgs.Semantic(hybrid.queryText(), hybrid.field()),
+ semanticWeight,
+ SearchQuery.Mode.SEMANTIC.name())
+ );
+ return new SearchInternal.FusionRequest(retrievers, defaultSize);
+ }
+
+ private static Float resolveSemanticWeight(Float matchWeight, Float semanticWeight) {
+ if (semanticWeight != null) {
+ return semanticWeight;
+ }
+ if (matchWeight != null) {
+ return 1.0f - matchWeight;
+ }
+ return null;
+ }
+
+ private static String requireHybridField(IndexMapping mapping, String field)
+ throws SearchException {
+ if (StringUtils.isNotEmpty(field)) {
+ validateHybridField(mapping, field);
+ return field;
+ }
+ return mapping
+ .soleHybridField()
+ .orElseThrow(
+ () ->
+ new SearchException(
+ "hybrid query requires field when index has "
+ + mapping.hybridFields().size()
+ + " hybrid field(s): "
+ + mapping.hybridFields()));
+ }
+
+ private static void validateHybridField(IndexMapping mapping, String field)
+ throws SearchException {
+ FieldSchema schema = mapping.fieldSchema(field);
+ if (!(schema instanceof FieldSchema.TextFieldSchema text) || !text.search().hybrid()) {
+ throw new SearchException("field '" + field + "' is not configured for hybrid search");
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/LuceneSearchBackend.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/LuceneSearchBackend.java
new file mode 100644
index 000000000000..f84386cdca96
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/LuceneSearchBackend.java
@@ -0,0 +1,108 @@
+/*
+ * 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.hive.search.search;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.hive.search.exception.IndexNotReadyException;
+import org.apache.hive.search.exception.InitializeException;
+import org.apache.hive.search.exception.SearchException;
+import org.apache.hive.search.index.IndexSession;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Phase 1 {@link SearchBackend} backed by an in-process Lucene index. */
+public final class LuceneSearchBackend implements SearchBackend {
+ private static final Logger LOG = LoggerFactory.getLogger(LuceneSearchBackend.class);
+ private IndexSession session;
+ private Future future;
+ private SearchConfig searchConfig;
+
+ @Override
+ public void initialize(Configuration configuration)
+ throws InitializeException, IOException {
+ searchConfig = new SearchConfig(configuration);
+ session = new IndexSession(configuration);
+ ExecutorService initThread = Executors.newFixedThreadPool(1);
+ future = initThread.submit(() -> {
+ long start = System.currentTimeMillis();
+ session.initialize();
+ LOG.info("The in-process Lucene search backend is ready for request now, time taken: {}ms",
+ System.currentTimeMillis() - start);
+ return null;
+ });
+ initThread.shutdown();
+ }
+
+ @Override
+ public boolean isReady() throws IndexNotReadyException {
+ if (future == null) {
+ throw new IndexNotReadyException("The in-process Lucene search backend hasn't been initialized yet");
+ }
+ try {
+ future.get(searchConfig.getInitReadyTimeoutMs(), TimeUnit.MILLISECONDS);
+ return true;
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause() != null ? e.getCause() : e;
+ throw new IndexNotReadyException("Search backend initialization failed", cause);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IndexNotReadyException("Search backend initialization interrupted", e);
+ } catch (TimeoutException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public TableSearchResult search(SearchQuery query)
+ throws SearchException, IOException {
+ if (!isReady()) {
+ throw new IndexNotReadyException("Search index is not ready");
+ }
+ List fields = query.returnFields();
+ if (query.returnFields().isEmpty()) {
+ fields = List.of(
+ MetastoreTableMapper.FIELD_DB,
+ MetastoreTableMapper.FIELD_TABLE,
+ MetastoreTableMapper.FIELD_OWNER,
+ MetastoreTableMapper.FIELD_COMMENT);
+ }
+ int limit = query.limit() > 0 ? query.limit() : searchConfig.getDefaultLimit();
+ query = SearchQuery.of(query, fields, limit);
+ try (SearchInternal searcher = session.getSearcher()) {
+ return searcher.search(query);
+ }
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (session != null) {
+ session.close();
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchArgs.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchArgs.java
new file mode 100644
index 000000000000..10f6a9c2b3f3
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchArgs.java
@@ -0,0 +1,237 @@
+/*
+ * 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.hive.search.search;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hive.search.exception.SearchException;
+
+/** Typed search arguments. Map/JSON parsing belongs in {@link #fromBody(Object, SearchQuery.Mode)}. */
+public sealed interface SearchArgs permits SearchArgs.Match, SearchArgs.Semantic, SearchArgs.Hybrid {
+
+ record Match(String queryText) implements SearchArgs {
+ public Match {
+ Objects.requireNonNull(queryText, "queryText");
+ }
+ }
+
+ record Semantic(String queryText, String field) implements SearchArgs {
+ public Semantic {
+ Objects.requireNonNull(queryText, "queryText");
+ }
+ }
+
+ record Hybrid(String queryText, String field, Float matchWeight, Float semanticWeight)
+ implements SearchArgs {
+ public Hybrid {
+ Objects.requireNonNull(queryText, "queryText");
+ }
+ }
+
+ /** Deserializes an API/Thrift request body into typed search args. */
+ static SearchArgs fromBody(Object body, SearchQuery.Mode mode) throws SearchException {
+ return switch (mode) {
+ case MATCH -> parseMatchBody(body);
+ case SEMANTIC -> parseSemanticBody(body);
+ case HYBRID -> parseHybridBody(body);
+ };
+ }
+
+ /** Serializes typed search args to a flat string map for Thrift. */
+ static Map toBody(SearchArgs args) {
+ return switch (args) {
+ case Match match -> Map.of("query", match.queryText());
+ case Semantic semantic -> toSemanticBody(semantic);
+ case Hybrid hybrid -> toHybridBody(hybrid);
+ };
+ }
+
+ /** Serializes typed search args to a Thrift-ready query body map. */
+ static Map toQueryBody(SearchArgs args) {
+ return toBody(args);
+ }
+
+ static SearchQuery.Mode modeOf(SearchArgs args) {
+ return switch (args) {
+ case Match m -> SearchQuery.Mode.MATCH;
+ case Semantic s -> SearchQuery.Mode.SEMANTIC;
+ case Hybrid h -> SearchQuery.Mode.HYBRID;
+ };
+ }
+
+ private static Map toSemanticBody(Semantic semantic) {
+ Map body = new LinkedHashMap<>();
+ body.put("query", semantic.queryText());
+ if (StringUtils.isNotEmpty(semantic.field())) {
+ body.put("field", semantic.field());
+ }
+ return body;
+ }
+
+ private static Map toHybridBody(Hybrid hybrid) {
+ Map body = new LinkedHashMap<>();
+ body.put("query", hybrid.queryText());
+ if (StringUtils.isNotEmpty(hybrid.field())) {
+ body.put("field", hybrid.field());
+ }
+ if (hybrid.matchWeight() != null) {
+ body.put("match_weight", hybrid.matchWeight().toString());
+ }
+ if (hybrid.semanticWeight() != null) {
+ body.put("semantic_weight", hybrid.semanticWeight().toString());
+ }
+ return body;
+ }
+
+ private static Match parseMatchBody(Object body) throws SearchException {
+ if (body instanceof String queryText) {
+ return new Match(queryText);
+ }
+ if (body instanceof Map, ?> matchMap && matchMap.containsKey("query")) {
+ return new Match(matchMap.get("query").toString());
+ }
+ throw new SearchException("match query must be a string or {query: text}");
+ }
+
+ private static Semantic parseSemanticBody(Object body) throws SearchException {
+ if (body instanceof String queryText) {
+ return new Semantic(queryText, null);
+ }
+ if (!(body instanceof Map, ?> semanticMap)) {
+ throw new SearchException("semantic query must be a string or object");
+ }
+ if (semanticMap.containsKey("field") && semanticMap.containsKey("query")) {
+ return new Semantic(
+ semanticMap.get("query").toString(),
+ semanticMap.get("field").toString());
+ }
+ if (semanticMap.containsKey("query") && !semanticMap.containsKey("field")) {
+ return new Semantic(semanticMap.get("query").toString(), null);
+ }
+ var fieldEntries = semanticMap.entrySet().stream()
+ .filter(entry -> entry.getValue() != null)
+ .toList();
+ if (fieldEntries.size() == 1) {
+ Map.Entry, ?> entry = fieldEntries.getFirst();
+ return new Semantic(entry.getValue().toString(), entry.getKey().toString());
+ }
+ throw new SearchException("semantic query must be a string, {field:, query:}, or {: text}");
+ }
+
+ private static Hybrid parseHybridBody(Object body) throws SearchException {
+ if (body instanceof String queryText) {
+ return new Hybrid(queryText, null, null, null);
+ }
+ if (!(body instanceof Map, ?> hybridMap)) {
+ throw new SearchException("hybrid query must be a string or object");
+ }
+ @SuppressWarnings("unchecked")
+ Map hybridBody = (Map) hybridMap;
+ Float matchWeight = parseHybridWeight(hybridBody, "match_weight", "match");
+ Float semanticWeight = parseHybridWeight(hybridBody, "semantic_weight", "semantic");
+ validateHybridWeights(matchWeight, semanticWeight);
+
+ if (hybridBody.containsKey("field") && hybridBody.containsKey("query")) {
+ return new Hybrid(
+ hybridBody.get("query").toString(),
+ hybridBody.get("field").toString(),
+ matchWeight,
+ semanticWeight);
+ }
+ if (hybridBody.containsKey("query")) {
+ return new Hybrid(
+ hybridBody.get("query").toString(),
+ null,
+ matchWeight,
+ semanticWeight);
+ }
+ List> fieldEntries = hybridBody.entrySet().stream()
+ .filter(entry -> !isHybridOption(entry.getKey()))
+ .filter(entry -> entry.getValue() != null)
+ .toList();
+ if (fieldEntries.size() == 1) {
+ Map.Entry entry = fieldEntries.getFirst();
+ return new Hybrid(
+ entry.getValue().toString(),
+ entry.getKey(),
+ matchWeight,
+ semanticWeight);
+ }
+ throw new SearchException(
+ "hybrid query must be a string, {field:, query:}, {query:}, or {: text}");
+ }
+
+ private static Float parseHybridWeight(
+ Map hybridBody, String flatKey, String nestedKey)
+ throws SearchException {
+ Object flatValue = hybridBody.get(flatKey);
+ if (flatValue != null) {
+ return parseWeightValue(flatValue, flatKey);
+ }
+ if (hybridBody.get("weights") instanceof Map, ?> weights) {
+ Object nestedValue = weights.get(nestedKey);
+ if (nestedValue != null) {
+ return parseWeightValue(nestedValue, nestedKey);
+ }
+ }
+ return null;
+ }
+
+ private static Float parseWeightValue(Object value, String key) throws SearchException {
+ if (value instanceof Number number) {
+ return number.floatValue();
+ }
+ if (value instanceof String text) {
+ try {
+ return Float.parseFloat(text);
+ } catch (NumberFormatException e) {
+ throw new SearchException("Invalid hybrid weight for '" + key + "': " + text);
+ }
+ }
+ throw new SearchException("Invalid hybrid weight for '" + key + "': " + value);
+ }
+
+ private static void validateHybridWeights(Float matchWeight, Float semanticWeight)
+ throws SearchException {
+ if (!isWeightValid(matchWeight)) {
+ throw new SearchException(
+ "Invalid hybrid lexical weight, it must be in (0, 1), but got " + matchWeight);
+ }
+ if (!isWeightValid(semanticWeight)) {
+ throw new SearchException(
+ "Invalid hybrid semantic weight, it must be in (0, 1), but got " + semanticWeight);
+ }
+ if (matchWeight != null && semanticWeight != null && (matchWeight + semanticWeight) > 1.0f) {
+ throw new SearchException("Invalid hybrid weights, sum of weights exceeds 1.0f");
+ }
+ }
+
+ private static boolean isWeightValid(Float weight) {
+ return weight == null || (weight > 0.0f && weight < 1.0f);
+ }
+
+ private static boolean isHybridOption(String key) {
+ return "match_weight".equals(key)
+ || "semantic_weight".equals(key)
+ || "weights".equals(key);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchBackend.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchBackend.java
new file mode 100644
index 000000000000..d85e8788f1e2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchBackend.java
@@ -0,0 +1,44 @@
+/*
+ * 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.hive.search.search;
+
+import java.io.IOException;
+
+import org.apache.hadoop.conf.Configuration;
+
+import org.apache.hive.search.exception.IndexNotReadyException;
+import org.apache.hive.search.exception.InitializeException;
+import org.apache.hive.search.exception.SearchException;
+
+/**
+ * Pluggable search storage and query engine. Phase 1 uses embedded Lucene; Phase 3 may add
+ * OpenSearch or Elasticsearch without changing the mapping contract or ingest path.
+ */
+public interface SearchBackend extends AutoCloseable {
+
+ void initialize(Configuration configuration) throws InitializeException, IOException;
+
+ /** Whether this instance can serve search (index opened and bootstrap completed). */
+ boolean isReady() throws IndexNotReadyException;
+
+ TableSearchResult search(SearchQuery query)
+ throws SearchException, InitializeException, IOException;
+
+ @Override
+ void close() throws Exception;
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchInternal.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchInternal.java
new file mode 100644
index 000000000000..efc6068f1d40
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchInternal.java
@@ -0,0 +1,286 @@
+/*
+ * 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.hive.search.search;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.exception.SearchException;
+import org.apache.hive.search.mapping.FieldSchema;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.apache.hive.search.index.IndexManager;
+import org.apache.hive.search.inference.EmbedModel;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.BayesianScoreEstimator;
+import org.apache.lucene.search.BayesianScoreQuery;
+import org.apache.lucene.search.BooleanClause;
+import org.apache.lucene.search.BooleanQuery;
+import org.apache.lucene.search.BoostQuery;
+import org.apache.lucene.search.ConstantScoreQuery;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.KnnFloatVectorQuery;
+import org.apache.lucene.search.LogOddsFusionQuery;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.SearcherManager;
+import org.apache.lucene.search.TermQuery;
+import org.apache.lucene.search.PrefixQuery;
+import org.apache.lucene.search.TopDocs;
+
+public final class SearchInternal implements AutoCloseable {
+ private final EmbedModelRegistry modelRegistry;
+ private final IndexSearcher searcher;
+ private final SearcherManager searcherManager;
+ private final IndexMapping mapping;
+ private final SearchConfig searchConfig;
+ private final BayesianScoreEstimator.Parameters parameters;
+
+ public SearchInternal(SearcherManager manager,
+ IndexManager indexManager,
+ EmbedModelRegistry registry,
+ SearchConfig searchConfig,
+ BayesianScoreEstimator.Parameters parameters) throws IOException {
+ this.searcherManager = manager;
+ this.searcher = manager.acquire();
+ this.mapping = indexManager.mapping();
+ this.searchConfig = searchConfig;
+ this.parameters = parameters;
+ this.modelRegistry = Objects.requireNonNull(registry, "Model registry");
+ }
+
+ public record RetrieverSpec(SearchArgs query, float weight, String name) {
+ }
+
+ public record FusionRequest(List retrievers, int size) {}
+
+ public TableSearchResult search(SearchQuery request)
+ throws SearchException, IOException {
+ int size = request.limit() > 0 ? request.limit() : searchConfig.getDefaultLimit();
+ Query query = compileRootQuery(request, size);
+ query = applyScopeFilter(query, request.catalogName(), request.databaseName());
+ TopDocs topDocs = searcher.search(query, size);
+ List hits = new ArrayList<>();
+ for (ScoreDoc scoreDoc : topDocs.scoreDocs) {
+ hits.add(readHit(scoreDoc, request.returnFields()));
+ }
+ return new TableSearchResult(hits, topDocs.totalHits.value());
+ }
+
+ private Query compileRootQuery(SearchQuery searchQuery, int size)
+ throws SearchException, IOException {
+ return switch (searchQuery.args()) {
+ case SearchArgs.Match match -> compileMatchQuery(match.queryText());
+ case SearchArgs.Semantic semantic ->
+ compileSemanticQuery(SemanticSearch.resolve(semantic, mapping), size);
+ case SearchArgs.Hybrid hybrid -> {
+ HybridSearch.ResolvedHybridQuery resolved = HybridSearch.resolve(hybrid, mapping);
+ yield compileFusionQuery(
+ HybridSearch.toFusionRequest(resolved, size, searchConfig), size);
+ }
+ };
+ }
+
+ private Query compileFusionQuery(FusionRequest fusion, int size)
+ throws SearchException, IOException {
+ int i = 0;
+ float[] weights = new float[fusion.retrievers.size()];
+ List queries = new ArrayList<>();
+ for (RetrieverSpec retriever : fusion.retrievers()) {
+ Query internalQuery = compileQuery(retriever.query(), size);
+ if (internalQuery instanceof KnnFloatVectorQuery) {
+ queries.add(internalQuery);
+ } else {
+ queries.add(new BayesianScoreQuery(internalQuery, parameters.alpha(),
+ parameters.beta(), parameters.baseRate()));
+ }
+ weights[i++] = retriever.weight();
+ }
+ return new LogOddsFusionQuery(queries, searchConfig.getFusionPrior(), weights);
+ }
+
+ private Query applyScopeFilter(Query query, String catalogName, String databaseName) {
+ Query filter = compileScopeFilter(catalogName, databaseName);
+ if (filter == null) {
+ return query;
+ }
+ return new BooleanQuery.Builder()
+ .add(query, BooleanClause.Occur.MUST)
+ .add(filter, BooleanClause.Occur.FILTER)
+ .build();
+ }
+
+ private Query compileScopeFilter(String catalogName, String databaseName) {
+ boolean hasCatalog = StringUtils.isNotEmpty(catalogName);
+ boolean hasDatabase = StringUtils.isNotEmpty(databaseName);
+ if (!hasCatalog && !hasDatabase) {
+ return null;
+ }
+ if (hasCatalog && hasDatabase) {
+ return new PrefixQuery(new Term("_id" + TableDocument.FILTER_SUFFIX,
+ catalogName + "." + databaseName + "."));
+ }
+ if (hasCatalog) {
+ return new PrefixQuery(new Term("_id" + TableDocument.FILTER_SUFFIX, catalogName + "."));
+ }
+ return new TermQuery(new Term(
+ MetastoreTableMapper.FIELD_DB + TableDocument.FILTER_SUFFIX, databaseName));
+ }
+
+ private Query compileQuery(SearchArgs args, int knnK)
+ throws SearchException, IOException {
+ return switch (args) {
+ case SearchArgs.Match match -> compileMatchQuery(match.queryText());
+ case SearchArgs.Semantic semantic ->
+ compileSemanticQuery(SemanticSearch.resolve(semantic, mapping), knnK);
+ case SearchArgs.Hybrid hybrid ->
+ throw new SearchException("Nested hybrid queries are not supported");
+ };
+ }
+
+ private Query compileMatchQuery(String queryText) throws SearchException, IOException {
+ BooleanQuery.Builder builder = new BooleanQuery.Builder();
+ boolean added = false;
+ for (MetastoreTableMapper.KeywordSearchField keywordField : MetastoreTableMapper.KEYWORD_SEARCH_FIELDS) {
+ String field = keywordField.field();
+ float boost = keywordField.boost();
+ FieldSchema schema = mapping.fieldSchema(field);
+ if (!(schema instanceof FieldSchema.TextFieldSchema text)) {
+ continue;
+ }
+ if (text.filter()) {
+ builder.add(
+ boostKeywordQuery(compileFilterKeywordQuery(field, queryText), boost),
+ BooleanClause.Occur.SHOULD);
+ if (text.search().lexical()) {
+ builder.add(
+ boostKeywordQuery(compileMatchQuery(field, queryText), boost),
+ BooleanClause.Occur.SHOULD);
+ }
+ added = true;
+ continue;
+ }
+ if (!text.search().lexical()) {
+ continue;
+ }
+ builder.add(
+ boostKeywordQuery(compileMatchQuery(field, queryText), boost),
+ BooleanClause.Occur.SHOULD);
+ added = true;
+ }
+ if (!added) {
+ throw new SearchException("No lexically searchable table fields are configured");
+ }
+ return builder.build();
+ }
+
+ private static Query boostKeywordQuery(Query query, float boost) {
+ return new BoostQuery(new ConstantScoreQuery(query), boost);
+ }
+
+ private Query compileFilterKeywordQuery(String field, String queryText) {
+ String normalized = queryText.trim().toLowerCase(Locale.ROOT);
+ if (normalized.isEmpty()) {
+ return new BooleanQuery.Builder().build();
+ }
+ return new TermQuery(new Term(field + TableDocument.FILTER_SUFFIX, normalized));
+ }
+
+ private Query compileMatchQuery(String field, String queryText)
+ throws SearchException, IOException {
+
+ FieldSchema schema = mapping.fieldSchema(field);
+ if (!(schema instanceof FieldSchema.TextFieldSchema text) || !text.search().lexical()) {
+ throw new SearchException("field '" + field + "' is not lexically searchable");
+ }
+
+ Analyzer analyzer = mapping.analyzer();
+ BooleanQuery.Builder builder = new BooleanQuery.Builder();
+ try (TokenStream stream = analyzer.tokenStream(field, queryText)) {
+ CharTermAttribute termAttr = stream.addAttribute(CharTermAttribute.class);
+ stream.reset();
+ while (stream.incrementToken()) {
+ builder.add(new TermQuery(new Term(field, termAttr.toString())), BooleanClause.Occur.SHOULD);
+ }
+ stream.end();
+ }
+ return builder.build();
+ }
+
+ private org.apache.lucene.search.Query compileSemanticQuery(
+ SemanticSearch.ResolvedSemanticQuery semantic, int knnK) throws SearchException {
+ FieldSchema schema = mapping.fieldSchema(semantic.field());
+ if (!(schema instanceof FieldSchema.TextFieldSchema text) || !text.search().semantic()) {
+ throw new SearchException("field '" + semantic.field() + "' is not semantically searchable");
+ }
+ float[] embedding;
+ try {
+ embedding = modelRegistry.get(text.search().semanticModel())
+ .embed(EmbedModel.TaskType.QUERY, semantic.queryText());
+ } catch (IndexException e) {
+ throw new SearchException(
+ "Failed to encode semantic query for field '" + semantic.field() + "'", e);
+ }
+ if (embedding == null) {
+ throw new SearchException(
+ "embedding model '" + text.search().semanticModel() + "' returned null vector");
+ }
+ return new KnnFloatVectorQuery(semantic.field(), embedding, knnK);
+ }
+
+ private TableSearchHit readHit(ScoreDoc scoreDoc, List fields)
+ throws IOException {
+ Document stored = searcher.storedFields().document(scoreDoc.doc);
+ Map fieldHits = new LinkedHashMap<>();
+ List requested = fields.isEmpty() ?
+ mapping.fields().keySet().stream().toList() : fields;
+ for (String field : requested) {
+ IndexableField[] values = stored.getFields(field);
+ if (values != null && values.length > 0) {
+ fieldHits.put(field, values[0].stringValue());
+ }
+ }
+ TableName tableName = null;
+ IndexableField[] idValues = stored.getFields("_id");
+ if (idValues != null && idValues.length > 0) {
+ tableName = TableName.fromString(idValues[0].stringValue(), "", "default");
+ }
+ return new TableSearchHit(tableName, scoreDoc.score, fieldHits);
+ }
+
+ @Override
+ public void close() throws IOException {
+ searcherManager.release(searcher);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchQuery.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchQuery.java
new file mode 100644
index 000000000000..976d58938a09
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SearchQuery.java
@@ -0,0 +1,117 @@
+/*
+ * 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.hive.search.search;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hive.search.exception.SearchException;
+
+public record SearchQuery(
+ SearchArgs args,
+ String catalogName,
+ String databaseName,
+ int limit,
+ List returnFields) {
+
+ /** Public search mode exposed on the Metastore Thrift API. */
+ public enum Mode {
+ MATCH,
+ SEMANTIC,
+ HYBRID
+ }
+
+ public SearchQuery {
+ returnFields = returnFields == null ? List.of() : List.copyOf(returnFields);
+ }
+
+ public Mode mode() {
+ return switch (args) {
+ case SearchArgs.Match m -> Mode.MATCH;
+ case SearchArgs.Semantic s -> Mode.SEMANTIC;
+ case SearchArgs.Hybrid h -> Mode.HYBRID;
+ };
+ }
+
+ private static void validate(SearchArgs args, int limit) throws SearchException {
+ String queryText = switch (args) {
+ case SearchArgs.Match m -> m.queryText();
+ case SearchArgs.Semantic s -> s.queryText();
+ case SearchArgs.Hybrid h -> h.queryText();
+ };
+ validate(queryText, limit);
+ }
+
+ private static void validate(String queryText, int limit) throws SearchException {
+ if (StringUtils.isEmpty(queryText)) {
+ throw new SearchException("queryText is required");
+ }
+ if (limit < 0) {
+ throw new SearchException("limit must be non-negative");
+ }
+ }
+
+ public static SearchQuery fromQueryBody(
+ Map queryBody,
+ Mode mode,
+ String catalogName,
+ String databaseName,
+ int limit,
+ List returnFields)
+ throws SearchException {
+ if (queryBody == null || queryBody.isEmpty()) {
+ throw new SearchException("missing query body for mode " + mode);
+ }
+ SearchArgs args = SearchArgs.fromBody(queryBody, mode);
+ validate(args, limit);
+ return new SearchQuery(args, catalogName, databaseName, limit, returnFields);
+ }
+
+ public static SearchQuery of(String queryText) throws SearchException {
+ validate(queryText, 0);
+ return new SearchQuery(new SearchArgs.Hybrid(queryText, null, null, null), null, null, 0, List.of());
+ }
+
+ public static SearchQuery of(String queryText, Mode mode, int limit) throws SearchException {
+ validate(queryText, limit);
+ SearchArgs args = switch (mode) {
+ case MATCH -> new SearchArgs.Match(queryText);
+ case SEMANTIC -> new SearchArgs.Semantic(queryText, null);
+ case HYBRID -> new SearchArgs.Hybrid(queryText, null, null, null);
+ };
+ return new SearchQuery(args, null, null, limit, List.of());
+ }
+
+ public static SearchQuery of(String keyWord, String catalogName, String databaseName)
+ throws SearchException {
+ validate(keyWord, 0);
+ return new SearchQuery(
+ new SearchArgs.Match(keyWord), catalogName, databaseName, 0, List.of());
+ }
+
+ public static SearchQuery of(SearchQuery query, List returnFields, int limit) {
+ return new SearchQuery(
+ query.args(), query.catalogName(), query.databaseName(), limit, returnFields);
+ }
+
+ /** Serializes this query to a flat Thrift-ready query body map. */
+ public Map toQueryBody() {
+ return SearchArgs.toQueryBody(args);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SemanticSearch.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SemanticSearch.java
new file mode 100644
index 000000000000..8645119a755c
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/SemanticSearch.java
@@ -0,0 +1,66 @@
+/*
+ * 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.hive.search.search;
+
+import java.util.List;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hive.search.exception.SearchException;
+import org.apache.hive.search.mapping.FieldSchema;
+import org.apache.hive.search.mapping.IndexMapping;
+
+public final class SemanticSearch {
+ private SemanticSearch() {}
+
+ public record ResolvedSemanticQuery(String field, String queryText) {}
+
+ public static ResolvedSemanticQuery resolve(SearchArgs.Semantic args, IndexMapping mapping)
+ throws SearchException {
+ String field = requireSemanticField(mapping, args.field());
+ return new ResolvedSemanticQuery(field, args.queryText());
+ }
+
+ private static String requireSemanticField(IndexMapping mapping, String field)
+ throws SearchException {
+ if (StringUtils.isNotEmpty(field)) {
+ validateSemanticField(mapping, field);
+ return field;
+ }
+ List semanticFields = mapping.fields().entrySet().stream()
+ .filter(entry -> entry.getValue() instanceof FieldSchema.TextFieldSchema text
+ && text.search().semantic())
+ .map(java.util.Map.Entry::getKey)
+ .toList();
+ if (semanticFields.size() == 1) {
+ return semanticFields.getFirst();
+ }
+ throw new SearchException(
+ "semantic query requires field when index has "
+ + semanticFields.size()
+ + " semantic field(s): "
+ + semanticFields);
+ }
+
+ private static void validateSemanticField(IndexMapping mapping, String field)
+ throws SearchException {
+ FieldSchema schema = mapping.fieldSchema(field);
+ if (!(schema instanceof FieldSchema.TextFieldSchema text) || !text.search().semantic()) {
+ throw new SearchException("field '" + field + "' is not semantically searchable");
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/TableSearchHit.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/TableSearchHit.java
new file mode 100644
index 000000000000..4ae0d07269fa
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/TableSearchHit.java
@@ -0,0 +1,36 @@
+/*
+ * 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.hive.search.search;
+
+import java.util.Map;
+
+import org.apache.hadoop.hive.common.TableName;
+
+/** One ranked discovery hit. Schema detail requires a canonical Metastore read. */
+public record TableSearchHit(
+ TableName table, float score, Map fields) {
+
+ public TableSearchHit {
+ fields = fields == null ? Map.of() : Map.copyOf(fields);
+ }
+
+ @Override
+ public String toString() {
+ return "TableSearchHit{" + "table=" + table + ", score=" + score + '}';
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/TableSearchResult.java b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/TableSearchResult.java
new file mode 100644
index 000000000000..7959db2873a2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/hive/search/search/TableSearchResult.java
@@ -0,0 +1,26 @@
+/*
+ * 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.hive.search.search;
+
+import java.util.List;
+
+public record TableSearchResult(List hits, long total) {
+ public TableSearchResult {
+ hits = hits == null ? List.of() : List.copyOf(hits);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/BayesianScoreEstimator.java b/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/BayesianScoreEstimator.java
new file mode 100644
index 000000000000..d13821214187
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/BayesianScoreEstimator.java
@@ -0,0 +1,232 @@
+/*
+ * 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.lucene.search;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.MultiTerms;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.index.Terms;
+import org.apache.lucene.index.TermsEnum;
+import org.apache.lucene.util.BytesRef;
+
+/**
+ * Estimates {@link BayesianScoreQuery} parameters (alpha, beta, base rate) from corpus statistics
+ * via pseudo-query sampling.
+ *
+ *
The estimation algorithm:
+ *
+ *
+ *
Reservoir-sample terms from the target field's indexed vocabulary
+ *
Partition the sampled terms into pseudo-queries
+ *
Run each pseudo-query via BM25 and collect the score distribution
+ *
Estimate base rate: mean fraction of documents scoring above the 95th percentile
+ *
+ *
+ * @lucene.experimental
+ */
+public class BayesianScoreEstimator {
+
+ /** Estimated parameters for {@link BayesianScoreQuery}. */
+ public record Parameters(float alpha, float beta, float baseRate) {}
+
+ private static final int DEFAULT_N_SAMPLES = 50;
+ private static final int DEFAULT_TOKENS_PER_QUERY = 5;
+ private static final double PERCENTILE_THRESHOLD = 0.95;
+ private static final float BASE_RATE_MIN = 1e-6f;
+ private static final float BASE_RATE_MAX = 0.5f;
+
+ private BayesianScoreEstimator() {}
+
+ /**
+ * Estimates BayesianScoreQuery parameters from the given index.
+ *
+ * @param searcher the index searcher to sample from
+ * @param field the indexed text field to create pseudo-queries for
+ * @param nSamples number of pseudo-queries to sample (default 50)
+ * @param tokensPerQuery number of indexed terms per pseudo-query (default 5)
+ * @param seed random seed for reproducible sampling
+ * @return estimated alpha, beta, and base rate
+ * @throws IOException if an I/O error occurs reading the index
+ */
+ public static Parameters estimate(
+ IndexSearcher searcher, String field, int nSamples, int tokensPerQuery, long seed)
+ throws IOException {
+ if (nSamples <= 0) {
+ throw new IllegalArgumentException("nSamples must be positive, got " + nSamples);
+ }
+ if (tokensPerQuery <= 0) {
+ throw new IllegalArgumentException("tokensPerQuery must be positive, got " + tokensPerQuery);
+ }
+
+ IndexReader reader = searcher.getIndexReader();
+ int maxDoc = reader.maxDoc();
+ if (maxDoc == 0) {
+ return new Parameters(1.0f, 0.0f, 0.01f);
+ }
+
+ Random rng = new Random(seed);
+ List sampledTerms =
+ sampleVocabularyTerms(reader, field, Math.multiplyExact(nSamples, tokensPerQuery), rng);
+ if (sampledTerms.isEmpty()) {
+ return new Parameters(1.0f, 0.0f, 0.01f);
+ }
+
+ // Create pseudo-queries from indexed vocabulary terms and collect scores.
+ List allScoreArrays = new ArrayList<>();
+ List baseRateFractions = new ArrayList<>();
+
+ for (int offset = 0; offset < sampledTerms.size(); offset += tokensPerQuery) {
+ BooleanQuery.Builder builder = new BooleanQuery.Builder();
+ int end = Math.min(offset + tokensPerQuery, sampledTerms.size());
+ for (int i = offset; i < end; i++) {
+ builder.add(
+ new TermQuery(new Term(field, sampledTerms.get(i))), BooleanClause.Occur.SHOULD);
+ }
+ Query pseudoQuery = builder.build();
+
+ // Collect all scores
+ float[] scores = collectScores(searcher, pseudoQuery, maxDoc);
+ if (scores.length == 0) {
+ continue;
+ }
+ allScoreArrays.add(scores);
+
+ // Base rate: fraction of docs above 95th percentile
+ float[] sorted = scores.clone();
+ Arrays.sort(sorted);
+ int pIdx = (int) (sorted.length * PERCENTILE_THRESHOLD);
+ pIdx = Math.min(pIdx, sorted.length - 1);
+ float threshold = sorted[pIdx];
+ int highCount = 0;
+ for (float s : scores) {
+ if (s >= threshold) {
+ highCount++;
+ }
+ }
+ baseRateFractions.add((float) highCount / maxDoc);
+ }
+
+ if (allScoreArrays.isEmpty()) {
+ return new Parameters(1.0f, 0.0f, 0.01f);
+ }
+
+ // Flatten all scores for global statistics
+ int totalScores = 0;
+ for (float[] arr : allScoreArrays) {
+ totalScores += arr.length;
+ }
+ float[] allScores = new float[totalScores];
+ int offset = 0;
+ for (float[] arr : allScoreArrays) {
+ System.arraycopy(arr, 0, allScores, offset, arr.length);
+ offset += arr.length;
+ }
+
+ // beta = median
+ Arrays.sort(allScores);
+ float beta = allScores[allScores.length / 2];
+
+ // alpha = 1 / std
+ double mean = 0;
+ for (float s : allScores) {
+ mean += s;
+ }
+ mean /= allScores.length;
+ double variance = 0;
+ for (float s : allScores) {
+ double diff = s - mean;
+ variance += diff * diff;
+ }
+ variance /= allScores.length;
+ double std = Math.sqrt(variance);
+ float alpha = std > 0 ? (float) (1.0 / std) : 1.0f;
+
+ // base rate = mean of per-query fractions, clamped
+ float baseRate = 0;
+ for (float f : baseRateFractions) {
+ baseRate += f;
+ }
+ baseRate /= baseRateFractions.size();
+ baseRate = Math.clamp(baseRate, BASE_RATE_MIN, BASE_RATE_MAX);
+
+ return new Parameters(alpha, beta, baseRate);
+ }
+
+ /**
+ * Estimates parameters with default settings (50 samples, 5 tokens per query, seed 42).
+ *
+ * @param searcher the index searcher
+ * @param field the text field
+ * @return estimated parameters
+ * @throws IOException if an I/O error occurs
+ */
+ public static Parameters estimate(IndexSearcher searcher, String field) throws IOException {
+ return estimate(searcher, field, DEFAULT_N_SAMPLES, DEFAULT_TOKENS_PER_QUERY, 42);
+ }
+
+ static List sampleVocabularyTerms(
+ IndexReader reader, String field, int sampleSize, Random rng) throws IOException {
+ Terms terms = MultiTerms.getTerms(reader, field);
+ if (terms == null) {
+ return new ArrayList<>();
+ }
+
+ List reservoir = new ArrayList<>(sampleSize);
+ TermsEnum termsEnum = terms.iterator();
+ BytesRef term;
+ long seen = 0;
+ while ((term = termsEnum.next()) != null) {
+ seen++;
+ if (reservoir.size() < sampleSize) {
+ reservoir.add(BytesRef.deepCopyOf(term));
+ } else {
+ long replacement = nextLong(rng, seen);
+ if (replacement < sampleSize) {
+ reservoir.set((int) replacement, BytesRef.deepCopyOf(term));
+ }
+ }
+ }
+ return reservoir;
+ }
+
+ private static long nextLong(Random rng, long bound) {
+ long bits;
+ long value;
+ do {
+ bits = rng.nextLong() >>> 1;
+ value = bits % bound;
+ } while (bits - value + (bound - 1) < 0L);
+ return value;
+ }
+
+ private static float[] collectScores(IndexSearcher searcher, Query query, int maxDoc)
+ throws IOException {
+ int topN = Math.min(maxDoc, 10000);
+ TopDocs topDocs = searcher.search(query, topN);
+ float[] scores = new float[topDocs.scoreDocs.length];
+ for (int i = 0; i < topDocs.scoreDocs.length; i++) {
+ scores[i] = topDocs.scoreDocs[i].score;
+ }
+ return scores;
+ }
+}
\ No newline at end of file
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/BayesianScoreQuery.java b/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/BayesianScoreQuery.java
new file mode 100644
index 000000000000..69036058ae1a
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/BayesianScoreQuery.java
@@ -0,0 +1,295 @@
+/*
+ * 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.lucene.search;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import org.apache.lucene.index.LeafReaderContext;
+
+/**
+ * A query wrapper that transforms the inner query's score into a calibrated probability via sigmoid
+ * calibration: {@code P = sigmoid(alpha * (score - beta))}.
+ *
+ *
This implements the query-level Bayesian transform from "Bayesian BM25": the inner query
+ * (typically a multi-term BooleanQuery with BM25Similarity) produces a raw score, and this wrapper
+ * maps it to a probability in (0, 1) suitable for combination with other probability signals via
+ * {@link LogOddsFusionQuery}.
+ *
+ *
The alpha parameter controls the sigmoid steepness (score sensitivity), and beta controls the
+ * midpoint (decision boundary). These can be set manually or estimated from the score distribution
+ * via {@link BayesianScoreEstimator}.
+ *
+ *
An optional base rate encodes the corpus-level prior probability that a random document is
+ * relevant to a random query. When set, the posterior is computed in log-odds space: {@code
+ * sigmoid(alpha * (score - beta) + logit(baseRate))}. This shifts scores down for rare-relevance
+ * corpora, improving calibration.
+ *
+ * @lucene.experimental
+ */
+public final class BayesianScoreQuery extends Query {
+
+ private final Query query;
+ private final float alpha;
+ private final float beta;
+ private final float baseRate;
+ private final float logitBaseRate;
+
+ /**
+ * Creates a BayesianScoreQuery with base rate.
+ *
+ * @param query the inner query whose scores will be transformed
+ * @param alpha sigmoid steepness (must be positive and finite)
+ * @param beta sigmoid midpoint (must be finite)
+ * @param baseRate corpus-level relevance prior in (0, 1), or 0 to disable. When positive, adds
+ * logit(baseRate) to the log-odds before sigmoid, shifting scores to account for the rarity
+ * of relevant documents.
+ */
+ public BayesianScoreQuery(Query query, float alpha, float beta, float baseRate) {
+ this.query = Objects.requireNonNull(query);
+ if (Float.isFinite(alpha) == false || alpha <= 0) {
+ throw new IllegalArgumentException("alpha must be a positive finite value, got " + alpha);
+ }
+ if (Float.isFinite(beta) == false) {
+ throw new IllegalArgumentException("beta must be a finite value, got " + beta);
+ }
+ if (baseRate < 0 || baseRate >= 1) {
+ throw new IllegalArgumentException("baseRate must be in [0, 1), got " + baseRate);
+ }
+ this.alpha = alpha;
+ this.beta = beta;
+ this.baseRate = baseRate;
+ if (baseRate > 0) {
+ this.logitBaseRate = (float) Math.log(baseRate / (1.0 - baseRate));
+ } else {
+ this.logitBaseRate = 0f;
+ }
+ }
+
+ /**
+ * Creates a BayesianScoreQuery without base rate.
+ *
+ * @param query the inner query whose scores will be transformed
+ * @param alpha sigmoid steepness (must be positive and finite)
+ * @param beta sigmoid midpoint (must be finite)
+ */
+ public BayesianScoreQuery(Query query, float alpha, float beta) {
+ this(query, alpha, beta, 0f);
+ }
+
+ /** Returns the wrapped query. */
+ public Query getQuery() {
+ return query;
+ }
+
+ /** Returns the sigmoid steepness parameter. */
+ public float getAlpha() {
+ return alpha;
+ }
+
+ /** Returns the sigmoid midpoint parameter. */
+ public float getBeta() {
+ return beta;
+ }
+
+ /** Returns the base rate, or 0 if not set. */
+ public float getBaseRate() {
+ return baseRate;
+ }
+
+ static float sigmoid(float x) {
+ if (x >= 0) {
+ return (float) (1.0 / (1.0 + Math.exp(-x)));
+ } else {
+ double expX = Math.exp(x);
+ return (float) (expX / (1.0 + expX));
+ }
+ }
+
+ @Override
+ public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost)
+ throws IOException {
+ Weight innerWeight = query.createWeight(searcher, scoreMode, boost);
+ if (scoreMode.needsScores() == false) {
+ return innerWeight;
+ }
+ return new BayesianScoreWeight(this, innerWeight);
+ }
+
+ @Override
+ public Query rewrite(IndexSearcher indexSearcher) throws IOException {
+ Query rewritten = query.rewrite(indexSearcher);
+ if (rewritten instanceof MatchNoDocsQuery) {
+ return rewritten;
+ }
+ if (rewritten != query) {
+ return new BayesianScoreQuery(rewritten, alpha, beta, baseRate);
+ }
+ return super.rewrite(indexSearcher);
+ }
+
+ @Override
+ public void visit(QueryVisitor visitor) {
+ query.visit(visitor.getSubVisitor(BooleanClause.Occur.MUST, this));
+ }
+
+ @Override
+ public String toString(String field) {
+ String base = "BayesianScore(" + query.toString(field) + ", alpha=" + alpha + ", beta=" + beta;
+ if (baseRate > 0) {
+ base += ", baseRate=" + baseRate;
+ }
+ return base + ")";
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return sameClassAs(other) && equalsTo(getClass().cast(other));
+ }
+
+ private boolean equalsTo(BayesianScoreQuery other) {
+ return query.equals(other.query)
+ && Float.floatToIntBits(alpha) == Float.floatToIntBits(other.alpha)
+ && Float.floatToIntBits(beta) == Float.floatToIntBits(other.beta)
+ && Float.floatToIntBits(baseRate) == Float.floatToIntBits(other.baseRate);
+ }
+
+ @Override
+ public int hashCode() {
+ int h = classHash();
+ h = 31 * h + query.hashCode();
+ h = 31 * h + Float.floatToIntBits(alpha);
+ h = 31 * h + Float.floatToIntBits(beta);
+ h = 31 * h + Float.floatToIntBits(baseRate);
+ return h;
+ }
+
+ private class BayesianScoreWeight extends Weight {
+ private final Weight innerWeight;
+
+ BayesianScoreWeight(Query query, Weight innerWeight) {
+ super(query);
+ this.innerWeight = innerWeight;
+ }
+
+ @Override
+ public Matches matches(LeafReaderContext context, int doc) throws IOException {
+ return innerWeight.matches(context, doc);
+ }
+
+ @Override
+ public Explanation explain(LeafReaderContext context, int doc) throws IOException {
+ Explanation innerExpl = innerWeight.explain(context, doc);
+ if (innerExpl.isMatch() == false) {
+ return innerExpl;
+ }
+ float innerScore = innerExpl.getValue().floatValue();
+ float logOdds = alpha * (innerScore - beta) + logitBaseRate;
+ float transformed = sigmoid(logOdds);
+ if (baseRate > 0) {
+ return Explanation.match(
+ transformed,
+ "sigmoid calibration with base rate, computed as"
+ + " sigmoid(alpha * (score - beta) + logit(baseRate)) from:",
+ innerExpl,
+ Explanation.match(alpha, "alpha, sigmoid steepness"),
+ Explanation.match(beta, "beta, sigmoid midpoint"),
+ Explanation.match(baseRate, "baseRate, corpus-level relevance prior"));
+ }
+ return Explanation.match(
+ transformed,
+ "sigmoid calibration, computed as sigmoid(alpha * (score - beta)) from:",
+ innerExpl,
+ Explanation.match(alpha, "alpha, sigmoid steepness"),
+ Explanation.match(beta, "beta, sigmoid midpoint"));
+ }
+
+ @Override
+ public ScorerSupplier scorerSupplier(LeafReaderContext context) throws IOException {
+ ScorerSupplier innerSupplier = innerWeight.scorerSupplier(context);
+ if (innerSupplier == null) {
+ return null;
+ }
+ return new ScorerSupplier() {
+ @Override
+ public Scorer get(long leadCost) throws IOException {
+ Scorer innerScorer = innerSupplier.get(leadCost);
+ return new BayesianScoreScorer(innerScorer);
+ }
+
+ @Override
+ public long cost() {
+ return innerSupplier.cost();
+ }
+
+ @Override
+ public void setTopLevelScoringClause() throws IOException {
+ innerSupplier.setTopLevelScoringClause();
+ }
+ };
+ }
+
+ @Override
+ public int count(LeafReaderContext context) throws IOException {
+ return innerWeight.count(context);
+ }
+
+ @Override
+ public boolean isCacheable(LeafReaderContext ctx) {
+ return innerWeight.isCacheable(ctx);
+ }
+ }
+
+ private class BayesianScoreScorer extends FilterScorer {
+
+ BayesianScoreScorer(Scorer in) {
+ super(in);
+ }
+
+ @Override
+ public float score() throws IOException {
+ float innerScore = in.score();
+ return sigmoid(alpha * (innerScore - beta) + logitBaseRate);
+ }
+
+ @Override
+ public int advanceShallow(int target) throws IOException {
+ return in.advanceShallow(target);
+ }
+
+ @Override
+ public float getMaxScore(int upTo) throws IOException {
+ float innerMax = in.getMaxScore(upTo);
+ // sigmoid is monotone, so max(sigmoid(f(x))) = sigmoid(max(f(x)))
+ return sigmoid(alpha * (innerMax - beta) + logitBaseRate);
+ }
+
+ @Override
+ public void setMinCompetitiveScore(float minScore) throws IOException {
+ // Invert the sigmoid to get the minimum inner score needed:
+ // minScore = sigmoid(alpha * (innerScore - beta) + logitBaseRate)
+ // => alpha * (innerScore - beta) + logitBaseRate = logit(minScore)
+ // => innerScore = (logit(minScore) - logitBaseRate) / alpha + beta
+ if (minScore > 0f && minScore < 1f) {
+ float clamped = Math.max(1e-7f, Math.min(1f - 1e-7f, minScore));
+ float logitMin = (float) Math.log(clamped / (1f - clamped));
+ float innerMin = (logitMin - logitBaseRate) / alpha + beta;
+ in.setMinCompetitiveScore(Math.max(0f, innerMin));
+ }
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/LogOddsFusionQuery.java b/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/LogOddsFusionQuery.java
new file mode 100644
index 000000000000..0b01afc15ec0
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/LogOddsFusionQuery.java
@@ -0,0 +1,479 @@
+/*
+ * 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.lucene.search;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.index.LeafReaderContext;
+
+/**
+ * A query that combines sub-query probability scores via log-odds fusion. Sub-queries are expected
+ * to produce scores in (0, 1) representing probabilities (e.g., from {@link BayesianScoreQuery}
+ * wrapping a BM25 query, or KNN cosine similarity).
+ *
+ *
The combination formula resolves the shrinkage problem of naive probabilistic AND by:
+ *
+ *
+ *
Converting each sub-score to log-odds: logit(p) = log(p / (1 - p))
+ *
Computing the mean log-odds across all clauses (non-matching contribute 0 = neutral)
+ *
The alpha parameter controls the confidence scaling exponent. The default alpha=0.5 implements
+ * the sqrt(n) scaling law from "From Bayesian Inference to Neural Computation".
+ *
+ *
Optional per-signal weights enable weighted Log-OP (Logarithmic Opinion Pooling) where each
+ * signal's log-odds contribution is scaled by its reliability weight. Weights must be non-negative
+ * and sum to 1. When weights are provided, the scoring formula becomes: {@code sigmoid(n^alpha *
+ * sum(w_i * softplus(logit(p_i))))} instead of the uniform mean.
+ *
+ * @see LogOddsFusionScorer
+ * @lucene.experimental
+ */
+public final class LogOddsFusionQuery extends Query implements Iterable {
+
+ private final Multiset clauses = new Multiset<>();
+ private final List orderedClauses;
+ private final float alpha;
+ private final float[] signalWeights;
+ private final float[] logitMin;
+ private final float[] logitMax;
+
+ /**
+ * Creates a new LogOddsFusionQuery with per-signal weights and optional logit normalization.
+ *
+ * @param clauses the sub-queries to combine
+ * @param alpha confidence scaling exponent (0.5 = sqrt(n) law)
+ * @param weights per-signal weights (must be non-negative, finite, and sum to 1.0), or null for
+ * uniform weighting
+ * @param logitMin per-signal logit lower bounds for normalization, or null to use softplus gating
+ * @param logitMax per-signal logit upper bounds for normalization, or null to use softplus gating
+ * @throws IllegalArgumentException if alpha is not in [0, 1], or weights/bounds are invalid
+ */
+ public LogOddsFusionQuery(
+ Collection extends Query> clauses,
+ float alpha,
+ float[] weights,
+ float[] logitMin,
+ float[] logitMax) {
+ Objects.requireNonNull(clauses, "Collection of Queries must not be null");
+ if (Float.isNaN(alpha) || alpha < 0 || alpha > 1) {
+ throw new IllegalArgumentException("alpha must be in [0, 1], got " + alpha);
+ }
+ if (weights != null) {
+ if (weights.length != clauses.size()) {
+ throw new IllegalArgumentException(
+ "weights length " + weights.length + " must equal clauses size " + clauses.size());
+ }
+ float sum = 0;
+ for (float w : weights) {
+ if (Float.isFinite(w) == false || w < 0) {
+ throw new IllegalArgumentException("weights must be non-negative and finite, got " + w);
+ }
+ sum += w;
+ }
+ if (Math.abs(sum - 1.0f) > 1e-3f) {
+ throw new IllegalArgumentException("weights must sum to 1.0, got " + sum);
+ }
+ this.signalWeights = weights.clone();
+ } else {
+ this.signalWeights = null;
+ }
+ if (logitMin != null && logitMax != null) {
+ if (logitMin.length != clauses.size()) {
+ throw new IllegalArgumentException(
+ "logitMin length " + logitMin.length + " must equal clauses size " + clauses.size());
+ }
+ if (logitMax.length != clauses.size()) {
+ throw new IllegalArgumentException(
+ "logitMax length " + logitMax.length + " must equal clauses size " + clauses.size());
+ }
+ this.logitMin = logitMin.clone();
+ this.logitMax = logitMax.clone();
+ } else {
+ this.logitMin = null;
+ this.logitMax = null;
+ }
+ this.alpha = alpha;
+ this.clauses.addAll(clauses);
+ this.orderedClauses = new ArrayList<>(clauses);
+ }
+
+ /**
+ * Creates a new LogOddsFusionQuery with per-signal weights (softplus gating, no normalization).
+ *
+ * @param clauses the sub-queries to combine
+ * @param alpha confidence scaling exponent (0.5 = sqrt(n) law)
+ * @param weights per-signal weights, or null for uniform weighting
+ * @throws IllegalArgumentException if alpha is not in [0, 1], or weights are invalid
+ */
+ public LogOddsFusionQuery(Collection extends Query> clauses, float alpha, float[] weights) {
+ this(clauses, alpha, weights, null, null);
+ }
+
+ /**
+ * Creates a new LogOddsFusionQuery with uniform weighting and softplus gating.
+ *
+ * @param clauses the sub-queries to combine
+ * @param alpha confidence scaling exponent (0.5 = sqrt(n) law)
+ * @throws IllegalArgumentException if alpha is not in [0, 1]
+ */
+ public LogOddsFusionQuery(Collection extends Query> clauses, float alpha) {
+ this(clauses, alpha, null, null, null);
+ }
+
+ /**
+ * Creates a new LogOddsFusionQuery with default alpha=0.5, uniform weighting, and softplus
+ * gating.
+ *
+ * @param clauses the sub-queries to combine
+ */
+ public LogOddsFusionQuery(Collection extends Query> clauses) {
+ this(clauses, 0.5f, null, null, null);
+ }
+
+ @Override
+ public Iterator iterator() {
+ return getClauses().iterator();
+ }
+
+ /** Returns the clauses. */
+ public Collection getClauses() {
+ return Collections.unmodifiableCollection(orderedClauses);
+ }
+
+ /** Returns the alpha (confidence scaling exponent). */
+ public float getAlpha() {
+ return alpha;
+ }
+
+ /**
+ * Returns a copy of the per-signal weights, or null if uniform weighting is used.
+ *
+ *
When non-null, the i-th element is the weight for the i-th clause in the order returned by
+ * {@link #getClauses()}.
+ */
+ public float[] getWeights() {
+ return signalWeights != null ? signalWeights.clone() : null;
+ }
+
+ /** Weight for LogOddsFusionQuery. */
+ protected class LogOddsFusionWeight extends Weight {
+
+ protected final ArrayList weights = new ArrayList<>();
+ private final ScoreMode scoreMode;
+
+ public LogOddsFusionWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost)
+ throws IOException {
+ super(LogOddsFusionQuery.this);
+ for (Query clauseQuery : orderedClauses) {
+ weights.add(searcher.createWeight(clauseQuery, scoreMode, boost));
+ }
+ this.scoreMode = scoreMode;
+ }
+
+ @Override
+ public Matches matches(LeafReaderContext context, int doc) throws IOException {
+ List mis = new ArrayList<>();
+ for (Weight weight : weights) {
+ Matches mi = weight.matches(context, doc);
+ if (mi != null) {
+ mis.add(mi);
+ }
+ }
+ return MatchesUtils.fromSubMatches(mis);
+ }
+
+ @Override
+ public ScorerSupplier scorerSupplier(LeafReaderContext context) throws IOException {
+ List scorerSuppliers = new ArrayList<>();
+ List activeWeightsList = signalWeights != null ? new ArrayList<>() : null;
+ List activeLogitMinList = logitMin != null ? new ArrayList<>() : null;
+ List activeLogitMaxList = logitMax != null ? new ArrayList<>() : null;
+
+ for (int i = 0; i < weights.size(); i++) {
+ ScorerSupplier ss = weights.get(i).scorerSupplier(context);
+ if (ss != null) {
+ scorerSuppliers.add(ss);
+ if (activeWeightsList != null) {
+ activeWeightsList.add(signalWeights[i]);
+ }
+ if (activeLogitMinList != null) {
+ activeLogitMinList.add(logitMin[i]);
+ activeLogitMaxList.add(logitMax[i]);
+ }
+ }
+ }
+
+ if (scorerSuppliers.isEmpty()) {
+ return null;
+ } else if (scorerSuppliers.size() == 1) {
+ return scorerSuppliers.get(0);
+ } else {
+ final int totalClauses = clauses.size();
+ final float[] activeWeights = toFloatArray(activeWeightsList);
+ final float[] activeMin = toFloatArray(activeLogitMinList);
+ final float[] activeMax = toFloatArray(activeLogitMaxList);
+
+ return new ScorerSupplier() {
+
+ private long cost = -1;
+
+ @Override
+ public Scorer get(long leadCost) throws IOException {
+ List scorers = new ArrayList<>();
+ for (ScorerSupplier ss : scorerSuppliers) {
+ scorers.add(ss.get(leadCost));
+ }
+ return new LogOddsFusionScorer(
+ scorers,
+ totalClauses,
+ alpha,
+ activeWeights,
+ activeMin,
+ activeMax,
+ scoreMode,
+ leadCost);
+ }
+
+ @Override
+ public long cost() {
+ if (cost == -1) {
+ long cost = 0;
+ for (ScorerSupplier ss : scorerSuppliers) {
+ cost += ss.cost();
+ }
+ this.cost = cost;
+ }
+ return cost;
+ }
+
+ @Override
+ public void setTopLevelScoringClause() throws IOException {
+ for (ScorerSupplier ss : scorerSuppliers) {
+ ss.setTopLevelScoringClause();
+ }
+ }
+ };
+ }
+ }
+
+ @Override
+ public boolean isCacheable(LeafReaderContext ctx) {
+ if (weights.size()
+ > AbstractMultiTermQueryConstantScoreWrapper.BOOLEAN_REWRITE_TERM_COUNT_THRESHOLD) {
+ return false;
+ }
+ for (Weight w : weights) {
+ if (w.isCacheable(ctx) == false) return false;
+ }
+ return true;
+ }
+
+ @Override
+ public Explanation explain(LeafReaderContext context, int doc) throws IOException {
+ boolean match = false;
+ List subsOnMatch = new ArrayList<>();
+ List subsOnNoMatch = new ArrayList<>();
+ double logitSum = 0;
+ int totalClauses = weights.size();
+
+ for (int i = 0; i < weights.size(); i++) {
+ Explanation e = weights.get(i).explain(context, doc);
+ if (e.isMatch()) {
+ match = true;
+ subsOnMatch.add(e);
+ float subScore = e.getValue().floatValue();
+ float rawLogit = LogOddsFusionScorer.logit(subScore);
+ float gated;
+ if (logitMin != null) {
+ float range = logitMax[i] - logitMin[i];
+ gated = range > 0 ? Math.clamp((rawLogit - logitMin[i]) / range, 0f, 1f) : 0.5f;
+ } else {
+ gated = LogOddsFusionScorer.softplus(rawLogit);
+ }
+ if (signalWeights != null) {
+ logitSum += signalWeights[i] * gated;
+ } else {
+ logitSum += gated;
+ }
+ } else if (match == false) {
+ subsOnNoMatch.add(e);
+ }
+ }
+
+ if (match) {
+ float scalingFactor = (float) Math.pow(totalClauses, alpha);
+ float scaledLogit = 0f;
+ String description;
+ if (signalWeights != null) {
+ scaledLogit = (float) logitSum * scalingFactor;
+ description =
+ "weighted log-odds fusion, computed as sigmoid(weightedLogit * n^alpha) from:";
+ } else {
+ scaledLogit = (float) (logitSum / totalClauses) * scalingFactor;
+ description = "log-odds fusion, computed as sigmoid(meanLogit * n^alpha) from:";
+ }
+ float score = LogOddsFusionScorer.sigmoid(scaledLogit);
+ return Explanation.match(score, description, subsOnMatch);
+ } else {
+ return Explanation.noMatch("No matching clause", subsOnNoMatch);
+ }
+ }
+ }
+
+ @Override
+ public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost)
+ throws IOException {
+ return new LogOddsFusionWeight(searcher, scoreMode, boost);
+ }
+
+ @Override
+ public Query rewrite(IndexSearcher indexSearcher) throws IOException {
+ if (clauses.isEmpty()) {
+ return new MatchNoDocsQuery("empty LogOddsFusionQuery");
+ }
+
+ if (clauses.size() == 1) {
+ return orderedClauses.get(0);
+ }
+
+ boolean actuallyRewritten = false;
+ List rewrittenClauses = new ArrayList<>();
+ List newWeights = signalWeights != null ? new ArrayList<>() : null;
+ List newLogitMin = logitMin != null ? new ArrayList<>() : null;
+ List newLogitMax = logitMax != null ? new ArrayList<>() : null;
+
+ for (int i = 0; i < orderedClauses.size(); i++) {
+ Query sub = orderedClauses.get(i);
+ Query rewrittenSub = sub.rewrite(indexSearcher);
+ if (rewrittenSub != sub || sub.getClass() == MatchNoDocsQuery.class) {
+ actuallyRewritten = true;
+ }
+ if (rewrittenSub.getClass() != MatchNoDocsQuery.class) {
+ rewrittenClauses.add(rewrittenSub);
+ if (newWeights != null) {
+ newWeights.add(signalWeights[i]);
+ }
+ if (newLogitMin != null) {
+ newLogitMin.add(logitMin[i]);
+ newLogitMax.add(logitMax[i]);
+ }
+ }
+ }
+
+ if (actuallyRewritten == false) {
+ return super.rewrite(indexSearcher);
+ }
+ if (rewrittenClauses.isEmpty()) {
+ return new MatchNoDocsQuery("empty LogOddsFusionQuery");
+ }
+ if (rewrittenClauses.size() == 1) {
+ return rewrittenClauses.get(0);
+ }
+
+ float[] filteredWeights = toFloatArray(newWeights);
+ if (filteredWeights != null) {
+ float sum = 0;
+ for (float w : filteredWeights) {
+ sum += w;
+ }
+ if (sum > 0) {
+ for (int i = 0; i < filteredWeights.length; i++) {
+ filteredWeights[i] /= sum;
+ }
+ }
+ }
+
+ return new LogOddsFusionQuery(
+ rewrittenClauses,
+ alpha,
+ filteredWeights,
+ toFloatArray(newLogitMin),
+ toFloatArray(newLogitMax));
+ }
+
+ @Override
+ public void visit(QueryVisitor visitor) {
+ QueryVisitor v = visitor.getSubVisitor(BooleanClause.Occur.SHOULD, this);
+ for (Query q : clauses) {
+ q.visit(v);
+ }
+ }
+
+ @Override
+ public String toString(String field) {
+ String base =
+ this.orderedClauses.stream()
+ .map(
+ subquery -> {
+ if (subquery instanceof BooleanQuery) {
+ return "(" + subquery.toString(field) + ")";
+ }
+ return subquery.toString(field);
+ })
+ .collect(Collectors.joining(" & ", "LogOdds(", ")^" + alpha));
+ if (signalWeights != null) {
+ return base + " w=" + Arrays.toString(signalWeights);
+ }
+ return base;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return sameClassAs(other) && equalsTo(getClass().cast(other));
+ }
+
+ private boolean equalsTo(LogOddsFusionQuery other) {
+ return alpha == other.alpha
+ && Objects.equals(clauses, other.clauses)
+ && Arrays.equals(signalWeights, other.signalWeights)
+ && Arrays.equals(logitMin, other.logitMin)
+ && Arrays.equals(logitMax, other.logitMax);
+ }
+
+ @Override
+ public int hashCode() {
+ int h = classHash();
+ h = 31 * h + Float.floatToIntBits(alpha);
+ h = 31 * h + Objects.hashCode(clauses);
+ h = 31 * h + Arrays.hashCode(signalWeights);
+ h = 31 * h + Arrays.hashCode(logitMin);
+ h = 31 * h + Arrays.hashCode(logitMax);
+ return h;
+ }
+
+ private static float[] toFloatArray(List list) {
+ if (list == null) {
+ return null;
+ }
+ float[] result = new float[list.size()];
+ for (int i = 0; i < list.size(); i++) {
+ result[i] = list.get(i);
+ }
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/LogOddsFusionScorer.java b/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/LogOddsFusionScorer.java
new file mode 100644
index 000000000000..5efa9d6deaf8
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/LogOddsFusionScorer.java
@@ -0,0 +1,219 @@
+/*
+ * 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.lucene.search;
+
+import java.io.IOException;
+import java.util.IdentityHashMap;
+import java.util.List;
+
+/**
+ * Scorer for {@link LogOddsFusionQuery}. Combines sub-scorer outputs (assumed to be probabilities
+ * in (0, 1)) via log-odds fusion with multiplicative confidence scaling.
+ *
+ *
The scoring formula is:
+ *
+ *
+ * gatedLogit = softplus(logit(clamp(subScore))) for each matching sub-scorer
+ * logitSum = sum of gatedLogit values
+ * meanLogit = logitSum / n (n = total clause count, not just matching)
+ * scaledLogit = meanLogit * pow(n, alpha)
+ * score = sigmoid(scaledLogit)
+ *
+ *
+ *
Softplus gating ({@code log(1 + exp(x))}) is applied to logit values before aggregation. This
+ * distinguishes "absence of evidence" (non-matching sub-scorer, contributes 0) from "evidence of
+ * absence" (matching sub-scorer with weak probability, contributes a small positive value). A
+ * matching sub-scorer always contributes more than a non-matching one, preserving the ordering
+ * among weak matches while ensuring that no match is ever penalized.
+ *
+ *
Non-matching sub-scorers contribute logit(0.5) = 0 (neutral evidence).
+ *
+ * @lucene.experimental
+ */
+final class LogOddsFusionScorer extends DisjunctionScorer {
+ private static final float CLAMP_MIN = 1e-7f;
+ private static final float CLAMP_MAX = 1f - 1e-7f;
+
+ private final List subScorers;
+ private final int totalClauses;
+ private final float scalingFactor;
+ private final float[] signalWeights;
+ private final float[] logitMin;
+ private final float[] logitMax;
+ private final IdentityHashMap scorerIndexMap;
+
+ private final DisjunctionScoreBlockBoundaryPropagator disjunctionBlockPropagator;
+
+ /**
+ * Creates a new LogOddsFusionScorer.
+ *
+ * @param subScorers the sub scorers to combine
+ * @param totalClauses the total number of clauses (including non-matching)
+ * @param alpha confidence scaling exponent (0.5 = sqrt(n) law)
+ * @param signalWeights per-signal weights parallel to subScorers (null for uniform weighting).
+ * When provided, the scoring formula uses weighted sum instead of mean. Weights must be
+ * non-negative and should sum to 1.
+ * @param logitMin per-signal logit lower bounds for normalization (null to use softplus gating).
+ * When provided together with logitMax, logit values are normalized to [0, 1] instead of
+ * applying softplus. This ensures non-negative contributions while preserving learned signal
+ * scale calibration.
+ * @param logitMax per-signal logit upper bounds for normalization (null to use softplus gating)
+ * @param scoreMode the score mode
+ * @param leadCost the lead cost for iteration
+ */
+ LogOddsFusionScorer(
+ List subScorers,
+ int totalClauses,
+ float alpha,
+ float[] signalWeights,
+ float[] logitMin,
+ float[] logitMax,
+ ScoreMode scoreMode,
+ long leadCost)
+ throws IOException {
+ super(subScorers, scoreMode, leadCost);
+ this.subScorers = subScorers;
+ this.totalClauses = totalClauses;
+ this.scalingFactor = (float) Math.pow(totalClauses, alpha);
+ this.signalWeights = signalWeights;
+ this.logitMin = logitMin;
+ this.logitMax = logitMax;
+ if (signalWeights != null) {
+ this.scorerIndexMap = new IdentityHashMap<>(subScorers.size());
+ for (int i = 0; i < subScorers.size(); i++) {
+ this.scorerIndexMap.put(subScorers.get(i), i);
+ }
+ } else {
+ this.scorerIndexMap = null;
+ }
+ if (scoreMode == ScoreMode.TOP_SCORES) {
+ this.disjunctionBlockPropagator = new DisjunctionScoreBlockBoundaryPropagator(subScorers);
+ } else {
+ this.disjunctionBlockPropagator = null;
+ }
+ }
+
+ static float clampProbability(float p) {
+ return Math.clamp(p, CLAMP_MIN, CLAMP_MAX);
+ }
+
+ static float logit(float p) {
+ float clamped = clampProbability(p);
+ return (float) Math.log(clamped / (1f - clamped));
+ }
+
+ static float sigmoid(float x) {
+ if (x >= 0) {
+ return (float) (1.0 / (1.0 + Math.exp(-x)));
+ } else {
+ double expX = Math.exp(x);
+ return (float) (expX / (1.0 + expX));
+ }
+ }
+
+ /**
+ * Softplus function: log(1 + exp(x)). Always positive, smooth approximation of ReLU. For large
+ * positive x, approaches x. For large negative x, approaches 0 from above. At x=0, returns log(2)
+ * ~ 0.693.
+ *
+ *
Uses a numerically stable formulation: for x > 20, softplus(x) ~ x.
+ */
+ static float softplus(float x) {
+ if (x > 20f) {
+ return x;
+ }
+ return (float) Math.log1p(Math.exp(x));
+ }
+
+ /** Applies gating to a logit value: normalization if bounds are set, softplus otherwise. */
+ private float gateLogit(float rawLogit, int signalIndex) {
+ if (logitMin != null) {
+ float range = logitMax[signalIndex] - logitMin[signalIndex];
+ if (range > 0) {
+ return Math.clamp((rawLogit - logitMin[signalIndex]) / range, 0f, 1f);
+ }
+ return 0.5f;
+ }
+ return softplus(rawLogit);
+ }
+
+ @Override
+ protected float score(DisiWrapper topList) throws IOException {
+ double logitSum = 0;
+ for (DisiWrapper w = topList; w != null; w = w.next) {
+ float subScore = w.scorable.score();
+ int idx = scorerIndexMap != null ? scorerIndexMap.get(w.scorer) : -1;
+ float gated = gateLogit(logit(subScore), idx >= 0 ? idx : 0);
+ if (scorerIndexMap != null) {
+ logitSum += signalWeights[idx] * gated;
+ } else {
+ logitSum += gated;
+ }
+ }
+ // Non-matching sub-scorers contribute 0.
+ // With weights: sum(w_i * gated_i) already accounts for the 1/n factor.
+ // Without weights: divide by totalClauses to compute the mean.
+ float scaledLogit = 0f;
+ if (signalWeights != null) {
+ scaledLogit = (float) logitSum * scalingFactor;
+ } else {
+ scaledLogit = (float) (logitSum / totalClauses) * scalingFactor;
+ }
+ return sigmoid(scaledLogit);
+ }
+
+ @Override
+ public int advanceShallow(int target) throws IOException {
+ if (disjunctionBlockPropagator != null) {
+ return disjunctionBlockPropagator.advanceShallow(target);
+ }
+ return super.advanceShallow(target);
+ }
+
+ @Override
+ public float getMaxScore(int upTo) throws IOException {
+ // Safe upper bound: gateLogit is monotone in p (both softplus and normalize are monotone),
+ // weights are non-negative, sum of upper bounds >= sum of actuals, and sigmoid is monotone.
+ double maxLogitSum = 0;
+ for (int i = 0; i < subScorers.size(); i++) {
+ Scorer scorer = subScorers.get(i);
+ if (scorer.docID() <= upTo) {
+ float maxSubScore = scorer.getMaxScore(upTo);
+ float gated = gateLogit(logit(maxSubScore), i);
+ if (signalWeights != null) {
+ maxLogitSum += signalWeights[i] * gated;
+ } else {
+ maxLogitSum += gated;
+ }
+ }
+ }
+ float scaledLogit = 0f;
+ if (signalWeights != null) {
+ scaledLogit = (float) maxLogitSum * scalingFactor;
+ } else {
+ scaledLogit = (float) (maxLogitSum / totalClauses) * scalingFactor;
+ }
+ return sigmoid(scaledLogit);
+ }
+
+ @Override
+ public void setMinCompetitiveScore(float minScore) throws IOException {
+ if (disjunctionBlockPropagator != null) {
+ disjunctionBlockPropagator.setMinCompetitiveScore(minScore);
+ }
+ }
+}
\ No newline at end of file
diff --git a/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/package-info.java b/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/package-info.java
new file mode 100644
index 000000000000..128958857d08
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/main/java/org/apache/lucene/search/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ */
+
+/**
+ * Classes in this package are copied from Lucene codebase.
+ */
+package org.apache.lucene.search;
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/config/TestIndexConfig.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/config/TestIndexConfig.java
new file mode 100644
index 000000000000..6cc4332aefd1
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/config/TestIndexConfig.java
@@ -0,0 +1,49 @@
+/*
+ * 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.hive.search.config;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.time.Duration;
+
+import static org.junit.Assert.assertEquals;
+
+@Category(MetastoreUnitTest.class)
+public class TestIndexConfig {
+
+ @Test
+ public void usesDefaultsWhenUnset() {
+ IndexConfig config = new IndexConfig(new Configuration(false));
+ assertEquals(IndexConfig.INDEX_NAME_DEFAULT, config.indexName());
+ assertEquals(IndexConfig.INDEX_RAM_SIZE_DEFAULT, config.getWriteBufferSize());
+ assertEquals(Duration.ofMillis(IndexConfig.FLUSH_INTERVAL_MS_DEFAULT), config.getFlushInterval());
+ }
+
+ @Test
+ public void readsConfiguredValues() {
+ Configuration conf = new Configuration(false);
+ conf.set(IndexConfig.INDEX_NAME, "custom_index");
+ conf.setInt(IndexConfig.BOOTSTRAP_BATCH_SIZE, 500);
+ IndexConfig config = new IndexConfig(conf);
+ assertEquals("custom_index", config.indexName());
+ assertEquals(500, config.getBootstrapBatchSize());
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/config/TestIndexStateConfig.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/config/TestIndexStateConfig.java
new file mode 100644
index 000000000000..4fccd37ae821
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/config/TestIndexStateConfig.java
@@ -0,0 +1,54 @@
+/*
+ * 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.hive.search.config;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.exception.IndexIOException;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestIndexStateConfig {
+
+ @Test
+ public void detectsRemoteAndMemoryFlags() {
+ Configuration conf = new Configuration(false);
+ conf.set(IndexStateConfig.REMOTE_URI, "file:///tmp/backup");
+ conf.setBoolean(IndexStateConfig.MEMORY, true);
+ IndexStateConfig config = new IndexStateConfig(conf, "hive_tables");
+ assertTrue(config.hasRemote());
+ assertTrue(config.isDistributed());
+ assertTrue(config.useMemory());
+ }
+
+ @Test
+ public void validateRemoteUriRejectsInvalidValue() {
+ assertThrows(IndexIOException.class, () -> IndexStateConfig.validateRemoteUri("://bad"));
+ }
+
+ @Test
+ public void localPathDefaultsToWorkdir() {
+ IndexStateConfig config = new IndexStateConfig(new Configuration(false), "hive_tables");
+ assertFalse(config.getLocalPath().toString().isEmpty());
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/config/TestSearchConfig.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/config/TestSearchConfig.java
new file mode 100644
index 000000000000..23d8d067f187
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/config/TestSearchConfig.java
@@ -0,0 +1,51 @@
+/*
+ * 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.hive.search.config;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.exception.SearchException;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.time.Duration;
+
+import static org.junit.Assert.assertEquals;
+
+@Category(MetastoreUnitTest.class)
+public class TestSearchConfig {
+
+ @Test
+ public void usesDefaultsWhenUnset() throws SearchException {
+ SearchConfig config = new SearchConfig(new Configuration(false));
+ assertEquals(Duration.ofSeconds(SearchConfig.REFRESH_INTERVAL_SECONDS_DEFAULT),
+ config.getRefreshInterval());
+ assertEquals(SearchConfig.DEFAULT_LIMIT_DEFAULT, config.getDefaultLimit());
+ assertEquals(SearchConfig.HYBRID_SEMANTIC_WEIGHT_DEFAULT, config.getHybridSemanticWeight(), 0.001f);
+ }
+
+ @Test
+ public void readsConfiguredValues() throws SearchException {
+ Configuration conf = new Configuration(false);
+ conf.setInt(SearchConfig.DEFAULT_LIMIT, 50);
+ conf.setFloat(SearchConfig.HYBRID_SEMANTIC_WEIGHT, 0.2f);
+ SearchConfig config = new SearchConfig(conf);
+ assertEquals(50, config.getDefaultLimit());
+ assertEquals(0.8f, config.getHybridMatchWeight(), 0.001f);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/TestIndexManagerFailureRecovery.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/TestIndexManagerFailureRecovery.java
new file mode 100644
index 000000000000..44c944b2f2e0
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/TestIndexManagerFailureRecovery.java
@@ -0,0 +1,54 @@
+/*
+ * 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.hive.search.index;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.config.IndexStateConfig;
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.exception.IndexNotHealthyException;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.metastore.MetastoreSchemas;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import static org.junit.Assert.assertThrows;
+
+@Category(MetastoreUnitTest.class)
+public class TestIndexManagerFailureRecovery {
+
+ @Test
+ public void unhealthyStatusBlocksSearchUntilRecovered() throws Exception {
+ Configuration conf = new Configuration(false);
+ conf.setBoolean(IndexStateConfig.MEMORY, true);
+ conf.set(IndexConfig.INDEX_NAME, "test_index");
+ conf.set(InferenceConfig.MODEL_NAME, "stub-model");
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping("test_index", "stub-model", conf);
+
+ try (IndexManager indexManager = IndexManager.open(mapping, conf)) {
+ IndexNotHealthyException failure =
+ new IndexNotHealthyException("notification apply failed");
+ indexManager.notifyIndexState(false, failure);
+ assertThrows(IndexNotHealthyException.class, indexManager::checkIndexState);
+
+ indexManager.notifyIndexState(true);
+ indexManager.checkIndexState();
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/TestIndexerEmbeddingCache.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/TestIndexerEmbeddingCache.java
new file mode 100644
index 000000000000..1d35be82409c
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/TestIndexerEmbeddingCache.java
@@ -0,0 +1,134 @@
+/*
+ * 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.hive.search.index;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.config.IndexStateConfig;
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.hive.search.inference.EmbeddingCache;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.metastore.MetastoreSchemas;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.apache.hive.search.search.InMemorySearchFixture;
+import org.apache.hive.search.testutil.StubEmbedModel;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestIndexerEmbeddingCache {
+
+ @Test
+ public void dedupesIdenticalSearchTextAcrossTables() throws Exception {
+ try (IndexerFixture fixture = IndexerFixture.create()) {
+ TableDocument salesOrders = document(fixture.mapping(),
+ InMemorySearchFixture.table("hive", "sales", "orders", "shared sales text"));
+ TableDocument inventoryOrders = document(fixture.mapping(),
+ InMemorySearchFixture.table("hive", "inventory", "orders", "shared sales text"));
+
+ fixture.indexer().embedDocuments(List.of(salesOrders));
+ int callsAfterFirst = fixture.model().encodeBatchCalls();
+ assertTrue(callsAfterFirst >= 1);
+
+ fixture.indexer().embedDocuments(List.of(inventoryOrders));
+ assertEquals(callsAfterFirst, fixture.model().encodeBatchCalls());
+ assertEquals(1, fixture.indexer().embeddingCache().hits());
+ }
+ }
+
+ @Test
+ public void skipsInferenceWhenSearchTextUnchangedOnUpdate() throws Exception {
+ try (IndexerFixture fixture = IndexerFixture.create()) {
+ Table table = InMemorySearchFixture.table("hive", "sales", "orders", "daily sales orders");
+ TableDocument original = document(fixture.mapping(), table);
+ fixture.indexer().embedDocuments(List.of(original));
+ int callsAfterInitial = fixture.model().encodeBatchCalls();
+
+ TableDocument updated = document(fixture.mapping(), table);
+ fixture.indexer().embedDocuments(List.of(updated));
+ assertEquals(callsAfterInitial, fixture.model().encodeBatchCalls());
+ assertEquals(1, fixture.indexer().embeddingCache().hits());
+ }
+ }
+
+ private static TableDocument document(IndexMapping mapping, Table table) {
+ return MetastoreTableMapper.fromTable(table, mapping);
+ }
+
+ private static final class IndexerFixture implements AutoCloseable {
+ private final IndexManager indexManager;
+ private final Indexer indexer;
+ private final IndexMapping mapping;
+ private final StubEmbedModel model;
+
+ private IndexerFixture(
+ IndexManager indexManager, Indexer indexer, IndexMapping mapping, StubEmbedModel model) {
+ this.indexManager = indexManager;
+ this.indexer = indexer;
+ this.mapping = mapping;
+ this.model = model;
+ }
+
+ static IndexerFixture create() throws Exception {
+ Configuration conf = new Configuration(false);
+ conf.setBoolean(IndexStateConfig.MEMORY, true);
+ conf.set(IndexConfig.INDEX_NAME, "test_index");
+ conf.set(InferenceConfig.MODEL_NAME, InMemorySearchFixture.MODEL_NAME);
+ conf.setBoolean(InferenceConfig.EMBEDDING_CACHE_ENABLED, true);
+ conf.setInt(InferenceConfig.EMBEDDING_CACHE_MAX_ENTRIES, 1000);
+
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping(
+ "test_index", InMemorySearchFixture.MODEL_NAME, conf);
+ StubEmbedModel model = new StubEmbedModel(InMemorySearchFixture.MODEL_NAME);
+ EmbedModelRegistry registry =
+ new EmbedModelRegistry(Map.of(model.name(), model), EmbeddingCache.create(conf));
+ IndexManager indexManager = IndexManager.open(mapping, conf);
+ Indexer indexer = new Indexer(indexManager, registry);
+ indexer.initialize();
+ return new IndexerFixture(indexManager, indexer, mapping, model);
+ }
+
+ IndexMapping mapping() {
+ return mapping;
+ }
+
+ Indexer indexer() {
+ return indexer;
+ }
+
+ StubEmbedModel model() {
+ return model;
+ }
+
+ @Override
+ public void close() throws Exception {
+ indexer.close();
+ indexManager.close();
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/TestIndexerFlushCommit.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/TestIndexerFlushCommit.java
new file mode 100644
index 000000000000..e07205b80554
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/TestIndexerFlushCommit.java
@@ -0,0 +1,125 @@
+/*
+ * 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.hive.search.index;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.config.IndexStateConfig;
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.metastore.MetastoreSchemas;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.apache.hive.search.search.InMemorySearchFixture;
+import org.apache.hive.search.testutil.StubEmbedModel;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestIndexerFlushCommit {
+
+ @Test
+ public void commitsAfterConfiguredFlushCount() throws Exception {
+ Configuration conf = baseConf();
+ conf.setInt(IndexConfig.INDEX_RAM_SIZE, 1024);
+ conf.setInt(IndexConfig.COMMIT_FLUSHES, 2);
+
+ try (IndexerFixture fixture = IndexerFixture.create(conf)) {
+ fixture.indexer.addDocuments(List.of(tableDoc(fixture.mapping(), "hive.sales.orders", "orders")));
+ assertFalse(fixture.indexer.flush(1L, false));
+ assertEquals(1, fixture.indexer.flushesSinceCommit());
+
+ fixture.indexer.addDocuments(List.of(tableDoc(fixture.mapping(), "hive.sales.customers", "customers")));
+ assertTrue(fixture.indexer.flush(2L, false));
+ assertEquals(0, fixture.indexer.flushesSinceCommit());
+ assertEquals(2L, fixture.indexManager.readLocalManifest().orElseThrow().lastEventId());
+ }
+ }
+
+ @Test
+ public void forceCommitIgnoresFlushThreshold() throws Exception {
+ Configuration conf = baseConf();
+ conf.setInt(IndexConfig.COMMIT_FLUSHES, 5);
+
+ try (IndexerFixture fixture = IndexerFixture.create(conf)) {
+ fixture.indexer.addDocuments(List.of(tableDoc(fixture.mapping(), "hive.sales.orders", "orders")));
+ assertTrue(fixture.indexer.flush(9L, true));
+ assertEquals(0, fixture.indexer.flushesSinceCommit());
+ assertEquals(9L, fixture.indexManager.readLocalManifest().orElseThrow().lastEventId());
+ }
+ }
+
+ private static Configuration baseConf() {
+ Configuration conf = new Configuration(false);
+ conf.setBoolean(IndexStateConfig.MEMORY, true);
+ conf.set(IndexConfig.INDEX_NAME, "test_index");
+ conf.set(InferenceConfig.MODEL_NAME, InMemorySearchFixture.MODEL_NAME);
+ return conf;
+ }
+
+ private static TableDocument tableDoc(IndexMapping mapping, String id, String comment) {
+ String[] parts = id.split("\\.");
+ return MetastoreTableMapper.fromTable(
+ InMemorySearchFixture.table(parts[0], parts[1], parts[2], comment), mapping);
+ }
+
+ private static final class IndexerFixture implements AutoCloseable {
+ private final IndexManager indexManager;
+ private final Indexer indexer;
+ private final EmbedModelRegistry modelRegistry;
+
+ private IndexerFixture(IndexManager indexManager, Indexer indexer, EmbedModelRegistry modelRegistry) {
+ this.indexManager = indexManager;
+ this.indexer = indexer;
+ this.modelRegistry = modelRegistry;
+ }
+
+ static IndexerFixture create(Configuration conf) throws IOException {
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping(
+ "test_index", InMemorySearchFixture.MODEL_NAME, conf);
+ IndexManager indexManager = IndexManager.open(mapping, conf);
+ EmbedModelRegistry registry = new EmbedModelRegistry(
+ Map.of(InMemorySearchFixture.MODEL_NAME,
+ new StubEmbedModel(InMemorySearchFixture.MODEL_NAME)));
+ Indexer indexer = new Indexer(indexManager, registry);
+ indexer.initialize();
+ return new IndexerFixture(indexManager, indexer, registry);
+ }
+
+ IndexMapping mapping() {
+ return indexManager.mapping();
+ }
+
+ @Override
+ public void close() throws Exception {
+ indexer.close();
+ indexManager.close();
+ modelRegistry.close();
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/TestIndexerSearchIntegration.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/TestIndexerSearchIntegration.java
new file mode 100644
index 000000000000..ffbc33c96061
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/TestIndexerSearchIntegration.java
@@ -0,0 +1,123 @@
+/*
+ * 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.hive.search.index;
+
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hive.search.search.InMemorySearchFixture;
+import org.apache.hive.search.search.TableSearchHit;
+import org.apache.hive.search.search.TableSearchResult;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestIndexerSearchIntegration {
+
+ @Test
+ public void keywordSearchFindsIndexedTable() throws Exception {
+ try (InMemorySearchFixture fixture = InMemorySearchFixture.create()) {
+ fixture.mutations().addTable(
+ InMemorySearchFixture.table("hive", "sales", "orders", "daily sales orders"));
+ fixture.mutations().addTable(
+ InMemorySearchFixture.table("hive", "inventory", "parts", "spare parts catalog"));
+ fixture.commit(1L);
+
+ List hits = fixture.searchMatch("sales", 5);
+ assertFalse(hits.isEmpty());
+ assertTrue(hits.stream().anyMatch(hit -> hit.table().toString().contains("sales.orders")));
+ }
+ }
+
+ @Test
+ public void tableKeywordMatchesTableNameWhenCommentDoesNot() throws Exception {
+ try (InMemorySearchFixture fixture = InMemorySearchFixture.create()) {
+ fixture.mutations().addTable(
+ InMemorySearchFixture.table("hive", "sales", "customers", "customer master data"));
+ fixture.commit(1L);
+
+ assertFalse(fixture.searchMatch("customers", 5).isEmpty());
+ }
+ }
+
+ @Test
+ public void tableKeywordRanksTableNameAboveColumnNameAboveComment() throws Exception {
+ try (InMemorySearchFixture fixture = InMemorySearchFixture.create()) {
+ fixture.mutations().addTable(
+ InMemorySearchFixture.table("hive", "sales", "orders", "revenue summary"));
+ fixture.mutations().addTable(tableWithColumn("hive", "sales", "line_items", "revenue"));
+ fixture.mutations().addTable(
+ InMemorySearchFixture.table("hive", "sales", "revenue", "monthly facts"));
+ fixture.commit(1L);
+
+ List hits = fixture.searchMatch("revenue", 5);
+ assertEquals(3, hits.size());
+ assertTrue(hits.get(0).table().toString().contains("sales.revenue"));
+ assertTrue(hits.get(1).table().toString().contains("sales.line_items"));
+ assertTrue(hits.get(2).table().toString().contains("sales.orders"));
+ }
+ }
+
+ private static Table tableWithColumn(String catalog, String db, String name, String columnName) {
+ Table table = InMemorySearchFixture.table(catalog, db, name, "unrelated comment");
+ table.getSd().setCols(List.of(
+ new org.apache.hadoop.hive.metastore.api.FieldSchema(columnName, "double", "amount value")));
+ return table;
+ }
+
+ @Test
+ public void incrementalDropRemovesTableFromSearchResults() throws Exception {
+ try (InMemorySearchFixture fixture = InMemorySearchFixture.create()) {
+ fixture.mutations().addTable(
+ InMemorySearchFixture.table("hive", "sales", "orders", "daily sales orders"));
+ fixture.commit(1L);
+ assertFalse(fixture.searchMatch("sales", 5).isEmpty());
+
+ fixture.mutations().dropTable(new TableName("hive", "sales", "orders"));
+ fixture.commit(2L);
+
+ List hits = fixture.searchMatch("sales", 5);
+ assertTrue(hits.isEmpty());
+ }
+ }
+
+ @Test
+ public void alterTableUpdatesSearchText() throws Exception {
+ try (InMemorySearchFixture fixture = InMemorySearchFixture.create()) {
+ var before = InMemorySearchFixture.table("hive", "sales", "orders", "old comment");
+ var after = InMemorySearchFixture.table("hive", "sales", "orders", "revenue analytics");
+ fixture.mutations().addTable(before);
+ fixture.commit(1L);
+ assertTrue(fixture.searchMatch("revenue", 5).isEmpty());
+
+ fixture.mutations().replaceTable(before, after);
+ fixture.commit(2L);
+
+ List hits = fixture.searchMatch("revenue", 5);
+ assertEquals(1, hits.size());
+ assertTrue(hits.get(0).table().toString().contains("sales.orders"));
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/manifest/TestIndexManifest.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/manifest/TestIndexManifest.java
new file mode 100644
index 000000000000..3edb4e851252
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/manifest/TestIndexManifest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.hive.search.index.manifest;
+
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestIndexManifest {
+
+ @Test
+ public void jsonRoundTrip() throws Exception {
+ IndexManifest manifest = IndexManifest.create(
+ "hive_tables",
+ List.of(
+ new IndexManifest.IndexFile("segments_1", 100),
+ new IndexManifest.IndexFile("segments_2", 200)),
+ "bge-small",
+ 42L);
+ IndexManifest parsed = IndexManifest.fromJson(manifest.toJsonBytes());
+ assertEquals(manifest, parsed);
+ }
+
+ @Test
+ public void sameFilesAsIgnoresOrder() {
+ IndexManifest left = IndexManifest.create(
+ "idx",
+ List.of(
+ new IndexManifest.IndexFile("a", 1),
+ new IndexManifest.IndexFile("b", 2)),
+ "model",
+ 1L);
+ IndexManifest right = IndexManifest.create(
+ "idx",
+ List.of(
+ new IndexManifest.IndexFile("b", 2),
+ new IndexManifest.IndexFile("a", 1)),
+ "model",
+ 2L);
+ assertTrue(left.sameFilesAs(right));
+ }
+
+ @Test
+ public void diffDetectsAddsUpdatesAndDeletes() {
+ IndexManifest source = IndexManifest.create(
+ "idx",
+ List.of(
+ new IndexManifest.IndexFile("keep", 10),
+ new IndexManifest.IndexFile("changed", 20),
+ new IndexManifest.IndexFile("added", 30)),
+ "model",
+ 5L);
+ IndexManifest target = IndexManifest.create(
+ "idx",
+ List.of(
+ new IndexManifest.IndexFile("keep", 10),
+ new IndexManifest.IndexFile("changed", 99),
+ new IndexManifest.IndexFile("removed", 40)),
+ "model",
+ 4L);
+
+ List ops = source.diff(target);
+ assertEquals(3, ops.size());
+ assertTrue(ops.contains(IndexManifest.ChangedFileOp.add("changed", 20L)));
+ assertTrue(ops.contains(IndexManifest.ChangedFileOp.add("added", 30L)));
+ assertTrue(ops.contains(IndexManifest.ChangedFileOp.del("removed")));
+ }
+
+ @Test
+ public void diffFromEmptyTargetAddsAllFiles() {
+ IndexManifest source = IndexManifest.create(
+ "idx",
+ List.of(new IndexManifest.IndexFile("segments_1", 100)),
+ "model",
+ 1L);
+
+ List ops = source.diff(null);
+ assertEquals(1, ops.size());
+ assertEquals(new IndexManifest.ChangedFileOp.Add("segments_1", 100L), ops.get(0));
+ }
+
+ @Test
+ public void diffAgainstIdenticalManifestIsEmpty() {
+ IndexManifest manifest = IndexManifest.create(
+ "idx",
+ List.of(new IndexManifest.IndexFile("segments_1", 100)),
+ "model",
+ 1L);
+ assertTrue(manifest.diff(manifest).isEmpty());
+ }
+
+ @Test
+ public void sameFilesAsReturnsFalseForDifferentSizes() {
+ IndexManifest left = IndexManifest.create(
+ "idx", List.of(new IndexManifest.IndexFile("a", 1)), "model", 1L);
+ IndexManifest right = IndexManifest.create(
+ "idx", List.of(new IndexManifest.IndexFile("a", 2)), "model", 1L);
+ assertFalse(left.sameFilesAs(right));
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/store/TestIndexBackupFailureRecovery.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/store/TestIndexBackupFailureRecovery.java
new file mode 100644
index 000000000000..ec5ae1b359f7
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/store/TestIndexBackupFailureRecovery.java
@@ -0,0 +1,120 @@
+/*
+ * 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.hive.search.index.store;
+
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.index.manifest.IndexManifest;
+import org.apache.hive.search.testutil.InMemoryIndexStateClient;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.io.ByteArrayInputStream;
+import java.util.List;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestIndexBackupFailureRecovery {
+
+ private InMemoryIndexStateClient local;
+ private InMemoryIndexStateClient remote;
+
+ @Before
+ public void setUp() {
+ local = new InMemoryIndexStateClient();
+ remote = new InMemoryIndexStateClient();
+ }
+
+ @Test
+ public void restoreResumesAfterPartialFileCopy() throws Exception {
+ IndexManifest target = manifest(20L, "segments_1", 100, "segments_2", 200);
+ writeManifest(remote, target);
+ remote.write("segments_1", new ByteArrayInputStream(new byte[] {1}));
+ remote.write("segments_2", new ByteArrayInputStream(new byte[] {2}));
+
+ local.write("segments_1", new ByteArrayInputStream(new byte[] {1}));
+ local.writeStagingManifest(target);
+
+ assertTrue(IndexBackupUtils.restoreFromBackup(local, remote));
+ assertTrue(local.hasFile("segments_2"));
+ assertFalse(local.readStagingManifest().isPresent());
+ }
+
+ @Test
+ public void resolveInterruptedRestoreKeepsStagingWhenFilesMismatch() throws Exception {
+ IndexManifest target = manifest(15L, "segments_1", 100, "segments_2", 200);
+ writeManifest(local, manifest(15L, "segments_1", 100));
+ local.writeStagingManifest(target);
+
+ IndexBackupUtils.resolveInterruptedRestore(local);
+
+ assertTrue(local.readStagingManifest().isPresent());
+ }
+
+ @Test
+ public void syncToBackupRemovesStaleRemoteFiles() throws Exception {
+ writeManifest(remote, manifest(5L, "segments_old", 50));
+ remote.write("segments_old", new ByteArrayInputStream(new byte[] {9}));
+
+ writeManifest(local, manifest(10L, "segments_new", 100));
+ local.write("segments_new", new ByteArrayInputStream(new byte[] {1, 2, 3}));
+
+ assertTrue(IndexBackupUtils.syncToBackup(local, remote));
+ assertFalse(remote.hasFile("segments_old"));
+ assertTrue(remote.hasFile("segments_new"));
+ }
+
+ @Test
+ public void restoreFromBackupRemovesStaleLocalFiles() throws Exception {
+ writeManifest(local, manifest(5L, "segments_old", 50));
+ local.write("segments_old", new ByteArrayInputStream(new byte[] {9}));
+
+ writeManifest(remote, manifest(20L, "segments_new", 100));
+ remote.write("segments_new", new ByteArrayInputStream(new byte[] {3, 2, 1}));
+
+ assertTrue(IndexBackupUtils.restoreFromBackup(local, remote));
+ assertFalse(local.hasFile("segments_old"));
+ assertTrue(local.hasFile("segments_new"));
+ }
+
+ private static IndexManifest manifest(long eventId, String fileName, long size) {
+ return IndexManifest.create(
+ "hive_tables",
+ List.of(new IndexManifest.IndexFile(fileName, size)),
+ "bge-small",
+ eventId);
+ }
+
+ private static IndexManifest manifest(
+ long eventId, String fileName1, long size1, String fileName2, long size2) {
+ return IndexManifest.create(
+ "hive_tables",
+ List.of(
+ new IndexManifest.IndexFile(fileName1, size1),
+ new IndexManifest.IndexFile(fileName2, size2)),
+ "bge-small",
+ eventId);
+ }
+
+ private static void writeManifest(InMemoryIndexStateClient client, IndexManifest manifest)
+ throws Exception {
+ client.write(IndexManifest.MANIFEST_FILE_NAME, new ByteArrayInputStream(manifest.toJsonBytes()));
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/store/TestIndexBackupUtils.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/store/TestIndexBackupUtils.java
new file mode 100644
index 000000000000..8fcf73c5ea4f
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/index/store/TestIndexBackupUtils.java
@@ -0,0 +1,111 @@
+/*
+ * 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.hive.search.index.store;
+
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.index.manifest.IndexManifest;
+import org.apache.hive.search.testutil.InMemoryIndexStateClient;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.io.ByteArrayInputStream;
+import java.util.List;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestIndexBackupUtils {
+
+ private InMemoryIndexStateClient local;
+ private InMemoryIndexStateClient remote;
+
+ @Before
+ public void setUp() {
+ local = new InMemoryIndexStateClient();
+ remote = new InMemoryIndexStateClient();
+ }
+
+ @Test
+ public void syncToBackupCopiesNewManifestAndFiles() throws Exception {
+ writeManifest(local, manifest(10L, "segments_1", 100));
+ local.write("segments_1", new ByteArrayInputStream(new byte[] {1, 2, 3}));
+
+ assertTrue(IndexBackupUtils.syncToBackup(local, remote));
+ assertTrue(remote.hasFile(IndexManifest.MANIFEST_FILE_NAME));
+ assertTrue(remote.hasFile("segments_1"));
+ assertEqualsEventId(10L, remote);
+ }
+
+ @Test
+ public void syncToBackupSkipsWhenRemoteIsNewer() throws Exception {
+ writeManifest(local, manifest(5L, "segments_1", 100));
+ writeManifest(remote, manifest(10L, "segments_1", 100));
+
+ assertFalse(IndexBackupUtils.syncToBackup(local, remote));
+ }
+
+ @Test
+ public void restoreFromBackupPullsRemoteState() throws Exception {
+ writeManifest(remote, manifest(20L, "segments_2", 200));
+ remote.write("segments_2", new ByteArrayInputStream(new byte[] {9, 8, 7}));
+
+ assertTrue(IndexBackupUtils.restoreFromBackup(local, remote));
+ assertTrue(local.hasFile("segments_2"));
+ assertFalse(local.readStagingManifest().isPresent());
+ }
+
+ @Test
+ public void restoreFromBackupSkipsWhenLocalIsNewer() throws Exception {
+ writeManifest(local, manifest(30L, "segments_1", 100));
+ writeManifest(remote, manifest(10L, "segments_1", 100));
+
+ assertFalse(IndexBackupUtils.restoreFromBackup(local, remote));
+ }
+
+ @Test
+ public void resolveInterruptedRestoreClearsMatchingStagingManifest() throws Exception {
+ IndexManifest target = manifest(15L, "segments_3", 300);
+ writeManifest(local, target);
+ local.writeStagingManifest(target);
+
+ IndexBackupUtils.resolveInterruptedRestore(local);
+
+ assertFalse(local.readStagingManifest().isPresent());
+ }
+
+ private static IndexManifest manifest(long eventId, String fileName, long size) {
+ return IndexManifest.create(
+ "hive_tables",
+ List.of(new IndexManifest.IndexFile(fileName, size)),
+ "bge-small",
+ eventId);
+ }
+
+ private static void writeManifest(InMemoryIndexStateClient client, IndexManifest manifest)
+ throws Exception {
+ client.write(IndexManifest.MANIFEST_FILE_NAME, new ByteArrayInputStream(manifest.toJsonBytes()));
+ }
+
+ private static void assertEqualsEventId(long expected, InMemoryIndexStateClient client)
+ throws Exception {
+ assertTrue(client.readManifest().isPresent());
+ assertTrue(expected == client.readManifest().get().lastEventId());
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/inference/TestEmbedModelRegistry.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/inference/TestEmbedModelRegistry.java
new file mode 100644
index 000000000000..fb95f4836093
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/inference/TestEmbedModelRegistry.java
@@ -0,0 +1,61 @@
+/*
+ * 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.hive.search.inference;
+
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.testutil.StubEmbedModel;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.util.Map;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestEmbedModelRegistry {
+
+ @Test
+ public void getReturnsConfiguredModel() throws Exception {
+ StubEmbedModel model = new StubEmbedModel("stub-model");
+ EmbedModelRegistry registry = new EmbedModelRegistry(Map.of("stub-model", model));
+ assertTrue(registry.get("stub-model") == model);
+ registry.close();
+ }
+
+ @Test
+ public void getThrowsForUnknownModel() {
+ EmbedModelRegistry registry = new EmbedModelRegistry(Map.of());
+ IndexException error = assertThrows(IndexException.class, () -> registry.get("missing"));
+ assertTrue(error.getMessage().contains("missing"));
+ }
+
+ @Test
+ public void stubModelProducesDeterministicVectors() throws Exception {
+ StubEmbedModel model = new StubEmbedModel("stub-model");
+ float[] first = model.embed(EmbedModel.TaskType.QUERY, "sales");
+ float[] second = model.embed(EmbedModel.TaskType.QUERY, "sales");
+ assertArrayEquals(first, second, 0.0001f);
+
+ float[] document = model.embed(EmbedModel.TaskType.DOCUMENT, "sales");
+ assertNotEquals(first[0], document[0], 0.0001f);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/inference/TestEmbeddingCache.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/inference/TestEmbeddingCache.java
new file mode 100644
index 000000000000..9ef2ca0ffbf2
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/inference/TestEmbeddingCache.java
@@ -0,0 +1,87 @@
+/*
+ * 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.hive.search.inference;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.config.InferenceConfig;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestEmbeddingCache {
+
+ @Test
+ public void getReturnsCopyOfStoredVector() {
+ EmbeddingCache cache = EmbeddingCache.create(enabledCacheConf());
+ float[] embedding = {0.1f, 0.2f, 0.3f};
+ cache.put("model-a", EmbedModel.TaskType.DOCUMENT, "sales orders", embedding);
+
+ float[] cached = cache.get("model-a", EmbedModel.TaskType.DOCUMENT, "sales orders").orElseThrow();
+ assertArrayEquals(embedding, cached, 0.001f);
+ cached[0] = 9f;
+ float[] again = cache.get("model-a", EmbedModel.TaskType.DOCUMENT, "sales orders").orElseThrow();
+ assertEquals(0.1f, again[0], 0.001f);
+ }
+
+ @Test
+ public void cacheIsScopedByModelAndTask() {
+ EmbeddingCache cache = EmbeddingCache.create(enabledCacheConf());
+ float[] doc = {1f};
+ float[] query = {2f};
+ cache.put("model-a", EmbedModel.TaskType.DOCUMENT, "text", doc);
+ cache.put("model-a", EmbedModel.TaskType.QUERY, "text", query);
+ cache.put("model-b", EmbedModel.TaskType.DOCUMENT, "text", new float[] {3f});
+
+ assertArrayEquals(doc, cache.get("model-a", EmbedModel.TaskType.DOCUMENT, "text").orElseThrow(), 0.001f);
+ assertArrayEquals(query, cache.get("model-a", EmbedModel.TaskType.QUERY, "text").orElseThrow(), 0.001f);
+ assertArrayEquals(new float[] {3f},
+ cache.get("model-b", EmbedModel.TaskType.DOCUMENT, "text").orElseThrow(), 0.001f);
+ }
+
+ @Test
+ public void disabledCacheAlwaysMisses() {
+ EmbeddingCache cache = EmbeddingCache.disabled();
+ cache.put("model-a", EmbedModel.TaskType.DOCUMENT, "text", new float[] {1f});
+ assertFalse(cache.get("model-a", EmbedModel.TaskType.DOCUMENT, "text").isPresent());
+ assertEquals(1, cache.misses());
+ }
+
+ @Test
+ public void tracksHitsAndMisses() {
+ EmbeddingCache cache = EmbeddingCache.create(enabledCacheConf());
+ cache.get("model-a", EmbedModel.TaskType.DOCUMENT, "missing");
+
+ cache.put("model-a", EmbedModel.TaskType.DOCUMENT, "sales", new float[] {1f});
+ assertTrue(cache.get("model-a", EmbedModel.TaskType.DOCUMENT, "sales").isPresent());
+ assertEquals(1, cache.hits());
+ assertEquals(1, cache.misses());
+ }
+
+ private static Configuration enabledCacheConf() {
+ Configuration conf = new Configuration(false);
+ conf.setBoolean(InferenceConfig.EMBEDDING_CACHE_ENABLED, true);
+ conf.setInt(InferenceConfig.EMBEDDING_CACHE_MAX_ENTRIES, 1000);
+ return conf;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/inference/TestEmbeddingPrompt.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/inference/TestEmbeddingPrompt.java
new file mode 100644
index 000000000000..3c1642e1962b
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/inference/TestEmbeddingPrompt.java
@@ -0,0 +1,42 @@
+/*
+ * 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.hive.search.inference;
+
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import static org.junit.Assert.assertEquals;
+
+@Category(MetastoreUnitTest.class)
+public class TestEmbeddingPrompt {
+
+ @Test
+ public void prefixForTaskType() {
+ EmbeddingPrompt prompt = EmbeddingPrompt.e5();
+ assertEquals("passage: ", prompt.prefixFor(EmbedModel.TaskType.DOCUMENT));
+ assertEquals("query: ", prompt.prefixFor(EmbedModel.TaskType.QUERY));
+ }
+
+ @Test
+ public void noneUsesEmptyPrefixes() {
+ EmbeddingPrompt prompt = EmbeddingPrompt.none();
+ assertEquals("", prompt.prefixFor(EmbedModel.TaskType.DOCUMENT));
+ assertEquals("", prompt.prefixFor(EmbedModel.TaskType.QUERY));
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/mapping/TestIndexMapping.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/mapping/TestIndexMapping.java
new file mode 100644
index 000000000000..634c00839b71
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/mapping/TestIndexMapping.java
@@ -0,0 +1,62 @@
+/*
+ * 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.hive.search.mapping;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.metastore.MetastoreSchemas;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestIndexMapping {
+
+ @Test
+ public void defaultSchemaExposesSingleHybridField() {
+ Configuration conf = new Configuration(false);
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping("hive_tables", "bge-small", conf);
+
+ assertEquals(1, mapping.hybridFields().size());
+ assertTrue(mapping.soleHybridField().isPresent());
+ assertEquals(MetastoreTableMapper.FIELD_SEARCH_TEXT, mapping.soleHybridField().get());
+ }
+
+ @Test
+ public void lexicalFieldsAreConfiguredInDefaultSchema() {
+ Configuration conf = new Configuration(false);
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping("hive_tables", "bge-small", conf);
+ FieldSchema tableSchema = mapping.fieldSchema(MetastoreTableMapper.FIELD_TABLE);
+ FieldSchema commentSchema = mapping.fieldSchema(MetastoreTableMapper.FIELD_COMMENT);
+ assertTrue(tableSchema instanceof FieldSchema.TextFieldSchema table
+ && table.search().lexical());
+ assertTrue(commentSchema instanceof FieldSchema.TextFieldSchema comment
+ && comment.search().lexical());
+ FieldSchema columnNamesSchema = mapping.fieldSchema(MetastoreTableMapper.FIELD_COLUMN_NAMES);
+ FieldSchema columnCommentsSchema = mapping.fieldSchema(MetastoreTableMapper.FIELD_COLUMN_COMMENTS);
+ assertTrue(columnNamesSchema instanceof FieldSchema.TextFieldSchema columnNames
+ && columnNames.search().lexical());
+ assertTrue(columnCommentsSchema instanceof FieldSchema.TextFieldSchema columnComments
+ && columnComments.search().lexical());
+ assertNotNull(mapping.analyzer());
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/RealMetastoreSearchSession.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/RealMetastoreSearchSession.java
new file mode 100644
index 000000000000..e190f9ba569f
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/RealMetastoreSearchSession.java
@@ -0,0 +1,250 @@
+/*
+ * 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.hive.search.metastore;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.HiveMetaStoreClient;
+import org.apache.hadoop.hive.metastore.MetaStoreTestUtils;
+import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
+import org.apache.hadoop.hive.metastore.conf.MetastoreConf.ConfVars;
+import org.apache.hadoop.hive.metastore.messaging.json.JSONMessageEncoder;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.config.IndexStateConfig;
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.hive.search.index.Indexer;
+import org.apache.hive.search.index.IndexManager;
+import org.apache.hive.search.index.store.LocalStateClient;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.search.SearchArgs;
+import org.apache.hive.search.search.SearchInternal;
+import org.apache.hive.search.search.SearchQuery;
+import org.apache.hive.search.search.TableSearchResult;
+import org.apache.hive.search.testutil.InMemoryIndexStateClient;
+import org.apache.hive.search.testutil.RealMetastoreServer;
+import org.apache.hive.search.testutil.StubEmbedModel;
+import org.apache.hive.search.testutil.TestLeaderElection;
+import org.apache.lucene.search.BayesianScoreEstimator;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.SearcherManager;
+import org.apache.lucene.store.ByteBuffersDirectory;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/** End-to-end search session wired to a real HMS and in-memory Lucene index. */
+public final class RealMetastoreSearchSession implements AutoCloseable {
+ public static final String MODEL_NAME = "stub-model";
+ private static final String ELECTION = "real-metastore-search-test";
+
+ private final IndexManager indexManager;
+ private final Indexer indexer;
+ private final EmbedModelRegistry modelRegistry;
+ private final MetastoreIndexer metastoreIndexer;
+ private final SearchConfig searchConfig;
+
+ private SearcherManager searcherManager;
+ private BayesianScoreEstimator.Parameters bayesianParameters;
+
+ private RealMetastoreSearchSession(
+ IndexManager indexManager,
+ Indexer indexer,
+ EmbedModelRegistry modelRegistry,
+ MetastoreIndexer metastoreIndexer,
+ SearchConfig searchConfig) {
+ this.indexManager = indexManager;
+ this.indexer = indexer;
+ this.modelRegistry = modelRegistry;
+ this.metastoreIndexer = metastoreIndexer;
+ this.searchConfig = searchConfig;
+ }
+
+ public static RealMetastoreSearchSession open(RealMetastoreServer server) throws Exception {
+ return open(server, null);
+ }
+
+ public static RealMetastoreSearchSession open(
+ RealMetastoreServer server, InMemoryIndexStateClient sharedRemote) throws Exception {
+ Configuration conf = searchConfiguration(server.conf());
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping(
+ "test_index", MODEL_NAME, conf);
+
+ ByteBuffersDirectory directory = new ByteBuffersDirectory();
+ LocalStateClient local = new LocalStateClient(directory, "test_index");
+ IndexManager indexManager = sharedRemote == null
+ ? new IndexManager(mapping, directory, local, null)
+ : new IndexManager(mapping, directory, local, sharedRemote);
+
+ EmbedModelRegistry modelRegistry = new EmbedModelRegistry(
+ Map.of(MODEL_NAME, new StubEmbedModel(MODEL_NAME)));
+ Indexer indexer = new Indexer(indexManager, modelRegistry);
+ HiveMetaStoreClient sessionClient = new HiveMetaStoreClient(server.conf());
+ MetastoreIndexer metastoreIndexer =
+ new MetastoreIndexer(conf, indexManager, indexer, sessionClient, false);
+ return new RealMetastoreSearchSession(
+ indexManager, indexer, modelRegistry, metastoreIndexer, new SearchConfig(conf));
+ }
+
+ public static InMemoryIndexStateClient newSharedRemoteBackup() {
+ return new InMemoryIndexStateClient();
+ }
+
+ public static String uniqueDbName(String prefix) {
+ return prefix + "_" + UUID.randomUUID().toString().replace('-', '_').substring(0, 8);
+ }
+
+ private static Configuration searchConfiguration(Configuration hmsConf) {
+ Configuration conf = new Configuration(false);
+ conf.setBoolean(IndexStateConfig.MEMORY, true);
+ conf.set(IndexConfig.INDEX_NAME, "test_index");
+ conf.set(InferenceConfig.MODEL_NAME, MODEL_NAME);
+ conf.setInt(IndexConfig.BOOTSTRAP_FETCH_THREADS, 2);
+ conf.setInt(IndexConfig.BOOTSTRAP_QUEUE_DEPTH, 8);
+ conf.setLong(IndexConfig.BOOTSTRAP_PROGRESS_INTERVAL_MS, Long.MAX_VALUE);
+ conf.setLong(IndexConfig.EVENT_FAILURE_BACKOFF_MS, 0L);
+ conf.setLong(IndexConfig.FLUSH_INTERVAL_MS, 60_000L);
+ conf.setLong(IndexConfig.INDEX_SYNC_INTERVAL, 60_000L);
+ conf.setInt(SearchConfig.BAYESIAN_SAMPLES, 8);
+ conf.setInt(SearchConfig.BAYESIAN_TOKENS_PER_QUERY, 3);
+ conf.setLong(SearchConfig.BAYESIAN_SEED, 1L);
+ MetastoreConf.setVar(conf, ConfVars.EVENT_MESSAGE_FACTORY, JSONMessageEncoder.class.getName());
+ MetastoreConf.setBoolVar(conf, ConfVars.CAPABILITY_CHECK, false);
+ MetastoreConf.setVar(conf, ConfVars.THRIFT_URIS, MetastoreConf.getVar(hmsConf, ConfVars.THRIFT_URIS));
+ MetastoreConf.setVar(conf, ConfVars.THRIFT_BIND_HOST, MetastoreConf.getVar(hmsConf, ConfVars.THRIFT_BIND_HOST));
+ TestLeaderElection election = new TestLeaderElection(true);
+ MetastoreCluster.injectElection(conf, ELECTION, election, "mutex");
+ return conf;
+ }
+
+ public void drainNotifications() throws Exception {
+ for (int round = 0; round < 20; round++) {
+ if (metastoreIndexer.pollEvents(100) == 0) {
+ break;
+ }
+ Thread.sleep(50);
+ }
+ metastoreIndexer.flushCheckpoint();
+ refreshSearcher();
+ }
+
+ public void waitUntilSearchable(String text, int limit) throws Exception {
+ MetaStoreTestUtils.waitForAssertion(
+ "table indexed for query '" + text + "'",
+ () -> {
+ try {
+ drainNotifications();
+ if (searchMatch(text, limit).hits().isEmpty()) {
+ throw new AssertionError("not searchable yet");
+ }
+ } catch (AssertionError e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ },
+ 30_000,
+ 300);
+ }
+
+ public void waitUntilNotSearchable(String text, int limit) throws Exception {
+ MetaStoreTestUtils.waitForAssertion(
+ "table removed for query '" + text + "'",
+ () -> {
+ try {
+ drainNotifications();
+ if (!searchMatch(text, limit).hits().isEmpty()) {
+ throw new AssertionError("still searchable for '" + text + "'");
+ }
+ } catch (RuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ },
+ 30_000,
+ 300);
+ }
+
+ public void syncBackup() throws IOException {
+ metastoreIndexer.syncBackup();
+ }
+
+ public void refreshSearcher() throws IOException {
+ if (searcherManager == null) {
+ searcherManager = new SearcherManager(indexer.writer(), null);
+ } else {
+ searcherManager.maybeRefresh();
+ }
+ if (bayesianParameters == null) {
+ IndexSearcher searcher = searcherManager.acquire();
+ try {
+ bayesianParameters = BayesianScoreEstimator.estimate(
+ searcher,
+ MetastoreTableMapper.FIELD_SEARCH_TEXT,
+ searchConfig.getBayesianSamples(),
+ searchConfig.getBayesianTokensPerQuery(),
+ searchConfig.getBayesianSeed());
+ } finally {
+ searcherManager.release(searcher);
+ }
+ }
+ }
+
+ public TableSearchResult searchMatch(String text, int limit) throws Exception {
+ refreshSearcher();
+ try (SearchInternal searchIO = new SearchInternal(
+ searcherManager, indexManager, modelRegistry, searchConfig, bayesianParameters)) {
+ return searchIO.search(new SearchQuery(
+ new SearchArgs.Match(text),
+ null, null, limit,
+ List.of(MetastoreTableMapper.FIELD_TABLE, MetastoreTableMapper.FIELD_COMMENT)));
+ }
+ }
+
+ public TableSearchResult searchHybrid(String queryText, int limit) throws Exception {
+ refreshSearcher();
+ try (SearchInternal searchIO = new SearchInternal(
+ searcherManager, indexManager, modelRegistry, searchConfig, bayesianParameters)) {
+ return searchIO.search(SearchQuery.fromQueryBody(
+ Map.of(
+ "field", MetastoreTableMapper.FIELD_SEARCH_TEXT,
+ "query", queryText),
+ SearchQuery.Mode.HYBRID,
+ null, null, limit,
+ List.of(MetastoreTableMapper.FIELD_TABLE, MetastoreTableMapper.FIELD_COMMENT)));
+ }
+ }
+
+ public IndexManager indexManager() {
+ return indexManager;
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (searcherManager != null) {
+ searcherManager.close();
+ }
+ metastoreIndexer.close();
+ indexer.close();
+ indexManager.close();
+ modelRegistry.close();
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestBootstrapIndexer.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestBootstrapIndexer.java
new file mode 100644
index 000000000000..60637af78828
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestBootstrapIndexer.java
@@ -0,0 +1,172 @@
+/*
+ * 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.hive.search.metastore;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.index.Indexer;
+import org.apache.hive.search.index.IndexManager;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.search.InMemorySearchFixture;
+import org.apache.hive.search.testutil.MetastoreBootstrapMocks;
+import org.apache.hive.search.testutil.StubEmbedModel;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@Category(MetastoreUnitTest.class)
+public class TestBootstrapIndexer {
+
+ @Test
+ public void bootstrapsAllTablesAcrossDatabases() throws Exception {
+ Configuration conf = bootstrapConf();
+ Table orders = InMemorySearchFixture.table("hive", "sales", "orders", "sales orders");
+ Table customers = InMemorySearchFixture.table("hive", "sales", "customers", "sales customers");
+ Table parts = InMemorySearchFixture.table("hive", "inventory", "parts", "spare parts");
+
+ try (BootstrapFixture fixture = BootstrapFixture.create(conf)) {
+ MetastoreBootstrapMocks.stubBootstrapCatalog(fixture.client(), orders, customers, parts);
+
+ new BootstrapIndexer(conf, fixture.mapping(), fixture.indexer(), fixture.client(), true)
+ .run(500L);
+
+ assertEquals(500L, fixture.indexManager().readLocalManifest().orElseThrow().lastEventId());
+ assertEquals(3, fixture.indexer().writer().getDocStats().numDocs);
+ }
+ }
+
+ @Test
+ public void bootstrapHonorsBatchSize() throws Exception {
+ Configuration conf = bootstrapConf();
+ conf.setInt(IndexConfig.BOOTSTRAP_BATCH_SIZE, 1);
+ conf.setInt(IndexConfig.BOOTSTRAP_FETCH_THREADS, 1);
+ Table orders = InMemorySearchFixture.table("hive", "sales", "orders", "sales orders");
+ Table customers = InMemorySearchFixture.table("hive", "sales", "customers", "sales customers");
+
+ try (BootstrapFixture fixture = BootstrapFixture.create(conf)) {
+ MetastoreBootstrapMocks.stubBootstrapCatalog(fixture.client(), orders, customers);
+
+ new BootstrapIndexer(conf, fixture.mapping(), fixture.indexer(), fixture.client(), true)
+ .run(10L);
+
+ assertEquals(2, fixture.indexer().writer().getDocStats().numDocs);
+ }
+ }
+
+ @Test
+ public void bootstrapFailsWhenMetastoreFetchFails() throws Exception {
+ Configuration conf = bootstrapConf();
+ try (BootstrapFixture fixture = BootstrapFixture.create(conf)) {
+ IMetaStoreClient client = fixture.client();
+ when(client.getAllDatabases()).thenReturn(List.of("sales"));
+ when(client.getAllTables("sales")).thenReturn(List.of("orders"));
+ when(client.getTableObjectsByName(eq("sales"), anyList()))
+ .thenThrow(new RuntimeException("metastore unavailable"));
+
+ IndexException error = assertThrows(IndexException.class,
+ () -> new BootstrapIndexer(conf, fixture.mapping(), fixture.indexer(), client, true)
+ .run(1L));
+ assertTrue(error.getMessage().contains("Bootstrap indexing failed")
+ || error.getMessage().contains("metastore unavailable"));
+ }
+ }
+
+ private static Configuration bootstrapConf() {
+ Configuration conf = new Configuration(false);
+ conf.setInt(IndexConfig.BOOTSTRAP_FETCH_THREADS, 1);
+ conf.setInt(IndexConfig.BOOTSTRAP_QUEUE_DEPTH, 4);
+ conf.setInt(IndexConfig.COMMIT_FLUSHES, 1);
+ conf.setLong(IndexConfig.BOOTSTRAP_PROGRESS_INTERVAL_MS, Long.MAX_VALUE);
+ return conf;
+ }
+
+ private static final class BootstrapFixture implements AutoCloseable {
+ private final IndexManager indexManager;
+ private final Indexer indexer;
+ private final EmbedModelRegistry modelRegistry;
+ private final IMetaStoreClient client;
+
+ private BootstrapFixture(
+ IndexManager indexManager,
+ Indexer indexer,
+ EmbedModelRegistry modelRegistry,
+ IMetaStoreClient client) {
+ this.indexManager = indexManager;
+ this.indexer = indexer;
+ this.modelRegistry = modelRegistry;
+ this.client = client;
+ }
+
+ static BootstrapFixture create(Configuration conf) throws Exception {
+ conf = new Configuration(conf);
+ conf.setBoolean(org.apache.hive.search.config.IndexStateConfig.MEMORY, true);
+ conf.set(IndexConfig.INDEX_NAME, "test_index");
+ conf.set(org.apache.hive.search.config.InferenceConfig.MODEL_NAME,
+ InMemorySearchFixture.MODEL_NAME);
+ org.apache.hive.search.mapping.IndexMapping mapping =
+ MetastoreSchemas.defaultHiveTablesMapping(
+ "test_index", InMemorySearchFixture.MODEL_NAME, conf);
+ IndexManager indexManager = IndexManager.open(mapping, conf);
+ EmbedModelRegistry registry = new EmbedModelRegistry(
+ Map.of(InMemorySearchFixture.MODEL_NAME,
+ new StubEmbedModel(InMemorySearchFixture.MODEL_NAME)));
+ Indexer indexer = new Indexer(indexManager, registry);
+ indexer.initialize();
+ return new BootstrapFixture(indexManager, indexer, registry, mock(IMetaStoreClient.class));
+ }
+
+ IndexManager indexManager() {
+ return indexManager;
+ }
+
+ Indexer indexer() {
+ return indexer;
+ }
+
+ IndexMapping mapping() {
+ return indexManager.mapping();
+ }
+
+ IMetaStoreClient client() {
+ return client;
+ }
+
+ @Override
+ public void close() throws Exception {
+ indexer.close();
+ indexManager.close();
+ modelRegistry.close();
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestMetastoreEventHandlerFailureRecovery.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestMetastoreEventHandlerFailureRecovery.java
new file mode 100644
index 000000000000..a9b2d0568163
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestMetastoreEventHandlerFailureRecovery.java
@@ -0,0 +1,258 @@
+/*
+ * 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.hive.search.metastore;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
+import org.apache.hadoop.hive.metastore.messaging.json.JSONMessageEncoder;
+import org.apache.hadoop.hive.metastore.api.NotificationEvent;
+import org.apache.hadoop.hive.metastore.api.NotificationEventRequest;
+import org.apache.hadoop.hive.metastore.api.NotificationEventResponse;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hadoop.hive.metastore.messaging.MessageBuilder;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.exception.IndexNotHealthyException;
+import org.apache.hive.search.search.InMemorySearchFixture;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@Category(MetastoreUnitTest.class)
+public class TestMetastoreEventHandlerFailureRecovery {
+
+ @Test
+ public void batchFailureFallsBackToSingleEventApply() throws Exception {
+ Configuration conf = eventHandlerConf();
+ conf.setInt(IndexConfig.EVENT_BATCH_MAX_FAILURES, 1);
+
+ MessageBuilder messageBuilder = MessageBuilder.getInstance();
+ Table orders = InMemorySearchFixture.table("hive", "sales", "orders", "sales orders");
+ Table customers = InMemorySearchFixture.table("hive", "sales", "customers", "sales customers");
+ NotificationEvent event1 = createEvent(101L, orders, messageBuilder);
+ NotificationEvent event2 = createEvent(102L, customers, messageBuilder);
+
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ when(client.getNextNotification(any(NotificationEventRequest.class), eq(true), eq(null)))
+ .thenReturn(response(event1, event2))
+ .thenReturn(emptyResponse());
+
+ try (InMemorySearchFixture fixture = InMemorySearchFixture.create()) {
+ AtomicInteger batchAttempts = new AtomicInteger();
+ MetastoreEventHandler handler = new MetastoreEventHandler(conf, client);
+ handler.addListeners(new MetastoreEventListener() {
+ @Override
+ public void notifyIndexTask(IndexTask task) throws IOException {
+ if (task.tablesToAdd.size() > 1) {
+ batchAttempts.incrementAndGet();
+ throw new IOException("simulated batch apply failure");
+ }
+ try {
+ fixture.mutations().apply(task);
+ fixture.commit(task.lastEventId);
+ } catch (Exception e) {
+ throw new IOException(e);
+ }
+ }
+ });
+
+ assertEquals(2, handler.getNextMetastoreEvents(10));
+ assertEquals(1, batchAttempts.get());
+ assertFalse(fixture.searchMatch("orders", 5).isEmpty());
+ assertFalse(fixture.searchMatch("customers", 5).isEmpty());
+ }
+ }
+
+ @Test
+ public void poisonEventMarksIndexUnhealthyAfterPartialApply() throws Exception {
+ Configuration conf = eventHandlerConf();
+ MessageBuilder messageBuilder = MessageBuilder.getInstance();
+ Table orders = InMemorySearchFixture.table("hive", "sales", "orders", "sales orders");
+ NotificationEvent good = createEvent(201L, orders, messageBuilder);
+ NotificationEvent poison = event(
+ 202L,
+ MessageBuilder.CREATE_TABLE_EVENT,
+ "{invalid-create-table-message",
+ "hive",
+ "sales",
+ "broken");
+
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ when(client.getNextNotification(any(NotificationEventRequest.class), eq(true), eq(null)))
+ .thenReturn(response(good, poison));
+
+ try (InMemorySearchFixture fixture = InMemorySearchFixture.create()) {
+ MetastoreEventHandler handler = new MetastoreEventHandler(conf, client);
+ handler.addListeners(new MetastoreEventListener() {
+ @Override
+ public void notifyIndexTask(IndexTask task) throws IOException {
+ try {
+ fixture.mutations().apply(task);
+ fixture.commit(task.lastEventId);
+ } catch (Exception e) {
+ throw new IOException(e);
+ }
+ }
+ });
+
+ IndexNotHealthyException error =
+ assertThrows(IndexNotHealthyException.class, () -> handler.getNextMetastoreEvents(10));
+ assertTrue(error.getMessage().contains("202"));
+ assertTrue(error.getMessage().contains("201"));
+ assertFalse(fixture.searchMatch("orders", 5).isEmpty());
+ }
+ }
+
+ @Test
+ public void skipPoisonEventAdvancesCursorAndRecovers() throws Exception {
+ Configuration conf = eventHandlerConf();
+ conf.setBoolean(IndexConfig.EVENT_SKIP_POISON, true);
+
+ MessageBuilder messageBuilder = MessageBuilder.getInstance();
+ Table orders = InMemorySearchFixture.table("hive", "sales", "orders", "sales orders");
+ NotificationEvent good = createEvent(301L, orders, messageBuilder);
+ NotificationEvent poison = event(
+ 302L,
+ MessageBuilder.CREATE_TABLE_EVENT,
+ "{invalid-create-table-message",
+ "hive",
+ "sales",
+ "broken");
+
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ when(client.getNextNotification(any(NotificationEventRequest.class), eq(true), eq(null)))
+ .thenReturn(response(good, poison))
+ .thenReturn(emptyResponse());
+
+ try (InMemorySearchFixture fixture = InMemorySearchFixture.create()) {
+ MetastoreEventHandler handler = new MetastoreEventHandler(conf, client);
+ handler.addListeners(new MetastoreEventListener() {
+ @Override
+ public void notifyIndexTask(IndexTask task) throws IOException {
+ try {
+ fixture.mutations().apply(task);
+ fixture.commit(task.lastEventId);
+ } catch (Exception e) {
+ throw new IOException(e);
+ }
+ }
+ });
+
+ assertEquals(2, handler.getNextMetastoreEvents(10));
+ assertFalse(fixture.searchMatch("orders", 5).isEmpty());
+ assertEquals(0, handler.getNextMetastoreEvents(10));
+ }
+ }
+
+ @Test
+ public void parseFailureFallsBackToSingleEventApply() throws Exception {
+ Configuration conf = eventHandlerConf();
+ MessageBuilder messageBuilder = MessageBuilder.getInstance();
+ Table orders = InMemorySearchFixture.table("hive", "sales", "orders", "sales orders");
+ NotificationEvent good = createEvent(401L, orders, messageBuilder);
+ NotificationEvent unparsableBatchMember = event(
+ 402L,
+ MessageBuilder.CREATE_TABLE_EVENT,
+ "{invalid-create-table-message",
+ "hive",
+ "sales",
+ "broken");
+
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ when(client.getNextNotification(any(NotificationEventRequest.class), eq(true), eq(null)))
+ .thenReturn(response(good, unparsableBatchMember));
+
+ try (InMemorySearchFixture fixture = InMemorySearchFixture.create()) {
+ AtomicBoolean sawBatchFailure = new AtomicBoolean();
+ MetastoreEventHandler handler = new MetastoreEventHandler(conf, client);
+ handler.addListeners(new MetastoreEventListener() {
+ @Override
+ public void notifyIndexTask(IndexTask task) throws IOException {
+ if (task.tablesToAdd.size() > 1) {
+ sawBatchFailure.set(true);
+ }
+ try {
+ fixture.mutations().apply(task);
+ fixture.commit(task.lastEventId);
+ } catch (Exception e) {
+ throw new IOException(e);
+ }
+ }
+ });
+
+ assertThrows(IndexNotHealthyException.class, () -> handler.getNextMetastoreEvents(10));
+ assertFalse(sawBatchFailure.get());
+ assertFalse(fixture.searchMatch("orders", 5).isEmpty());
+ }
+ }
+
+ private static Configuration eventHandlerConf() {
+ Configuration conf = new Configuration(false);
+ MetastoreConf.setVar(conf, MetastoreConf.ConfVars.EVENT_MESSAGE_FACTORY,
+ JSONMessageEncoder.class.getName());
+ conf.setLong(IndexConfig.EVENT_FAILURE_BACKOFF_MS, 0L);
+ conf.setInt(IndexConfig.EVENT_BATCH_MAX_FAILURES, 3);
+ return conf;
+ }
+
+ private static NotificationEvent createEvent(long id, Table table, MessageBuilder builder) {
+ return event(
+ id,
+ MessageBuilder.CREATE_TABLE_EVENT,
+ builder.buildCreateTableMessage(table, null).toString(),
+ table.getCatName(),
+ table.getDbName(),
+ table.getTableName());
+ }
+
+ private static NotificationEvent event(
+ long id, String type, String message, String cat, String db, String table) {
+ NotificationEvent event = new NotificationEvent(id, 0, type, message);
+ event.setCatName(cat);
+ event.setDbName(db);
+ event.setTableName(table);
+ return event;
+ }
+
+ private static NotificationEventResponse response(NotificationEvent... events) {
+ NotificationEventResponse response = new NotificationEventResponse();
+ response.setEvents(List.of(events));
+ return response;
+ }
+
+ private static NotificationEventResponse emptyResponse() {
+ NotificationEventResponse response = new NotificationEventResponse();
+ response.setEvents(new ArrayList<>());
+ return response;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestMetastoreEventHandlerIntegration.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestMetastoreEventHandlerIntegration.java
new file mode 100644
index 000000000000..6c606d0eea97
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestMetastoreEventHandlerIntegration.java
@@ -0,0 +1,232 @@
+/*
+ * 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.hive.search.metastore;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
+import org.apache.hadoop.hive.metastore.messaging.json.JSONMessageEncoder;
+import org.apache.hadoop.hive.metastore.api.NotificationEvent;
+import org.apache.hadoop.hive.metastore.api.NotificationEventRequest;
+import org.apache.hadoop.hive.metastore.api.NotificationEventResponse;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hadoop.hive.metastore.messaging.MessageBuilder;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.search.InMemorySearchFixture;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@Category(MetastoreUnitTest.class)
+public class TestMetastoreEventHandlerIntegration {
+
+ @Test
+ public void pollsNotificationsAndUpdatesIndex() throws Exception {
+ Configuration conf = new Configuration(false);
+ MetastoreConf.setVar(conf, MetastoreConf.ConfVars.EVENT_MESSAGE_FACTORY,
+ JSONMessageEncoder.class.getName());
+ conf.setLong(IndexConfig.EVENT_FAILURE_BACKOFF_MS, 0L);
+ conf.setInt(IndexConfig.EVENT_BATCH_MAX_FAILURES, 1);
+
+ MessageBuilder messageBuilder = MessageBuilder.getInstance();
+ Table salesOrders = InMemorySearchFixture.table("hive", "sales", "orders", "daily sales orders");
+ String createMessage = messageBuilder.buildCreateTableMessage(salesOrders, null).toString();
+
+ NotificationEvent createEvent = event(101L, MessageBuilder.CREATE_TABLE_EVENT, createMessage,
+ "hive", "sales", "orders");
+ NotificationEvent dropEvent = event(
+ 102L,
+ MessageBuilder.DROP_TABLE_EVENT,
+ messageBuilder.buildDropTableMessage(salesOrders).toString(),
+ "hive",
+ "sales",
+ "orders");
+
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ when(client.getNextNotification(any(NotificationEventRequest.class), eq(true), eq(null)))
+ .thenReturn(response(createEvent))
+ .thenReturn(response(dropEvent))
+ .thenReturn(emptyResponse());
+
+ try (InMemorySearchFixture fixture = InMemorySearchFixture.create()) {
+ AtomicReference lastTask = new AtomicReference<>();
+ MetastoreEventHandler handler = new MetastoreEventHandler(conf, client);
+ handler.addListeners(new MetastoreEventListener() {
+ @Override
+ public void notifyIndexTask(IndexTask task) throws IOException {
+ lastTask.set(task);
+ try {
+ fixture.mutations().apply(task);
+ fixture.commit(task.lastEventId);
+ } catch (Exception e) {
+ throw new IOException(e);
+ }
+ }
+ });
+
+ assertEquals(1, handler.getNextMetastoreEvents(10));
+ assertEquals(1, lastTask.get().tablesToAdd.size());
+ assertFalse(fixture.searchMatch("sales", 5).isEmpty());
+
+ assertEquals(1, handler.getNextMetastoreEvents(10));
+ assertEquals(1, lastTask.get().tablesToDrop.size());
+ assertTrue(fixture.searchMatch("sales", 5).isEmpty());
+ }
+ }
+
+ @Test
+ public void skipsAlterWhenIndexedFieldsUnchanged() throws Exception {
+ Configuration conf = new Configuration(false);
+ MetastoreConf.setVar(conf, MetastoreConf.ConfVars.EVENT_MESSAGE_FACTORY,
+ JSONMessageEncoder.class.getName());
+
+ MessageBuilder messageBuilder = MessageBuilder.getInstance();
+ Table before = InMemorySearchFixture.table("hive", "sales", "orders", "daily sales orders");
+ Table after = new Table(before);
+ after.setParameters(new java.util.HashMap<>(before.getParameters()));
+ after.getParameters().put("transient_lastDdlTime", "999");
+ String alterMessage = messageBuilder.buildAlterTableMessage(before, after, false, -1L).toString();
+
+ NotificationEvent create = event(
+ 301L,
+ MessageBuilder.CREATE_TABLE_EVENT,
+ messageBuilder.buildCreateTableMessage(before, null).toString(),
+ "hive",
+ "sales",
+ "orders");
+ NotificationEvent alter = event(
+ 302L,
+ MessageBuilder.ALTER_TABLE_EVENT,
+ alterMessage,
+ "hive",
+ "sales",
+ "orders");
+
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ when(client.getNextNotification(any(NotificationEventRequest.class), eq(true), eq(null)))
+ .thenReturn(response(create))
+ .thenReturn(response(alter))
+ .thenReturn(emptyResponse());
+
+ try (InMemorySearchFixture fixture = InMemorySearchFixture.create()) {
+ AtomicReference lastTask = new AtomicReference<>();
+ MetastoreEventHandler handler = new MetastoreEventHandler(conf, client);
+ handler.addListeners(new MetastoreEventListener() {
+ @Override
+ public void notifyIndexTask(IndexTask task) throws IOException {
+ lastTask.set(task);
+ try {
+ fixture.mutations().apply(task);
+ fixture.commit(task.lastEventId);
+ } catch (Exception e) {
+ throw new IOException(e);
+ }
+ }
+ });
+
+ assertEquals(1, handler.getNextMetastoreEvents(10));
+ assertEquals(1, lastTask.get().tablesToAdd.size());
+ assertFalse(fixture.searchMatch("sales", 5).isEmpty());
+
+ assertEquals(1, handler.getNextMetastoreEvents(10));
+ assertTrue(lastTask.get().tablesToAdd.isEmpty());
+ assertTrue(lastTask.get().tablesToDrop.isEmpty());
+ assertFalse(fixture.searchMatch("sales", 5).isEmpty());
+ }
+ }
+
+ @Test
+ public void coalesceCreateThenDropInSameBatch() throws Exception {
+ Configuration conf = new Configuration(false);
+ MetastoreConf.setVar(conf, MetastoreConf.ConfVars.EVENT_MESSAGE_FACTORY,
+ JSONMessageEncoder.class.getName());
+ MessageBuilder messageBuilder = MessageBuilder.getInstance();
+ Table table = InMemorySearchFixture.table("hive", "sales", "orders", "sales table");
+ NotificationEvent create = event(
+ 201L,
+ MessageBuilder.CREATE_TABLE_EVENT,
+ messageBuilder.buildCreateTableMessage(table, null).toString(),
+ "hive",
+ "sales",
+ "orders");
+ NotificationEvent drop = event(
+ 202L,
+ MessageBuilder.DROP_TABLE_EVENT,
+ messageBuilder.buildDropTableMessage(table).toString(),
+ "hive",
+ "sales",
+ "orders");
+
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ when(client.getNextNotification(any(NotificationEventRequest.class), eq(true), eq(null)))
+ .thenReturn(response(create, drop))
+ .thenReturn(emptyResponse());
+
+ try (InMemorySearchFixture fixture = InMemorySearchFixture.create()) {
+ MetastoreEventHandler handler = new MetastoreEventHandler(conf, client);
+ handler.addListeners(new MetastoreEventListener() {
+ @Override
+ public void notifyIndexTask(IndexTask task) throws IOException {
+ try {
+ fixture.mutations().apply(task);
+ fixture.commit(task.lastEventId);
+ } catch (Exception e) {
+ throw new IOException(e);
+ }
+ }
+ });
+
+ assertEquals(2, handler.getNextMetastoreEvents(10));
+ assertTrue(fixture.searchMatch("sales", 5).isEmpty());
+ }
+ }
+
+ private static NotificationEvent event(
+ long id, String type, String message, String cat, String db, String table) {
+ NotificationEvent event = new NotificationEvent(id, 0, type, message);
+ event.setCatName(cat);
+ event.setDbName(db);
+ event.setTableName(table);
+ return event;
+ }
+
+ private static NotificationEventResponse response(NotificationEvent... events) {
+ NotificationEventResponse response = new NotificationEventResponse();
+ response.setEvents(List.of(events));
+ return response;
+ }
+
+ private static NotificationEventResponse emptyResponse() {
+ NotificationEventResponse response = new NotificationEventResponse();
+ response.setEvents(new ArrayList<>());
+ return response;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestMetastoreIndexer.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestMetastoreIndexer.java
new file mode 100644
index 000000000000..4b7b5f268b8f
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestMetastoreIndexer.java
@@ -0,0 +1,196 @@
+/*
+ * 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.hive.search.metastore;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
+import org.apache.hadoop.hive.metastore.messaging.json.JSONMessageEncoder;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.config.IndexStateConfig;
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.hive.search.index.Indexer;
+import org.apache.hive.search.index.IndexManager;
+import org.apache.hive.search.index.store.LocalStateClient;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.search.InMemorySearchFixture;
+import org.apache.hive.search.testutil.InMemoryIndexStateClient;
+import org.apache.hive.search.testutil.MetastoreBootstrapMocks;
+import org.apache.hive.search.testutil.StubEmbedModel;
+import org.apache.hive.search.testutil.TestLeaderElection;
+import org.apache.lucene.store.ByteBuffersDirectory;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+@Category(MetastoreUnitTest.class)
+public class TestMetastoreIndexer {
+
+ private static final String ELECTION = "metastore-indexer-test";
+
+ @Test
+ public void leaderBootstrapsEmptyIndex() throws Exception {
+ Configuration conf = indexerConf(true);
+ Table orders = InMemorySearchFixture.table("hive", "sales", "orders", "sales orders");
+ Table customers = InMemorySearchFixture.table("hive", "sales", "customers", "sales customers");
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ MetastoreBootstrapMocks.stubCurrentNotificationId(client, 500L);
+ MetastoreBootstrapMocks.stubCatchUp(client);
+ MetastoreBootstrapMocks.stubBootstrapCatalog(client, orders, customers);
+
+ try (IndexerFixture fixture = IndexerFixture.create(conf, null)) {
+ try (MetastoreIndexer metastoreIndexer =
+ new MetastoreIndexer(conf, fixture.indexManager, fixture.indexer, client)) {
+ assertEquals(500L, fixture.indexManager.readLocalManifest().orElseThrow().lastEventId());
+ assertEquals(2, fixture.indexer.writer().getDocStats().numDocs);
+ }
+ }
+ }
+
+ @Test
+ public void reusesValidLocalIndexWithoutRebuild() throws Exception {
+ Configuration conf = indexerConf(true);
+ Table orders = InMemorySearchFixture.table("hive", "sales", "orders", "sales orders");
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ MetastoreBootstrapMocks.stubCatchUp(client);
+
+ try (IndexerFixture fixture = IndexerFixture.create(conf, null)) {
+ fixture.indexer.initialize();
+ List docs = List.of(
+ MetastoreTableMapper.fromTable(orders, fixture.indexManager.mapping()));
+ fixture.indexer.addDocuments(docs);
+ fixture.indexer.flush(42L, true);
+ fixture.indexer.close();
+
+ fixture.indexer = new Indexer(fixture.indexManager, fixture.modelRegistry);
+ try (MetastoreIndexer metastoreIndexer =
+ new MetastoreIndexer(conf, fixture.indexManager, fixture.indexer, client)) {
+ verify(client, never()).getAllDatabases();
+ assertEquals(42L, fixture.indexManager.readLocalManifest().orElseThrow().lastEventId());
+ assertEquals(1, fixture.indexer.writer().getDocStats().numDocs);
+ }
+ }
+ }
+
+ @Test
+ public void followerRestoresFromRemoteBackup() throws Exception {
+ Configuration conf = indexerConf(false);
+ Table orders = InMemorySearchFixture.table("hive", "sales", "orders", "sales orders");
+ Table customers = InMemorySearchFixture.table("hive", "sales", "customers", "sales customers");
+ InMemoryIndexStateClient remote = new InMemoryIndexStateClient();
+
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping(
+ "test_index", InMemorySearchFixture.MODEL_NAME, conf);
+ EmbedModelRegistry registry = new EmbedModelRegistry(
+ Map.of(InMemorySearchFixture.MODEL_NAME, new StubEmbedModel(InMemorySearchFixture.MODEL_NAME)));
+
+ ByteBuffersDirectory leaderDir = new ByteBuffersDirectory();
+ IndexManager leaderManager = new IndexManager(
+ mapping, leaderDir, new LocalStateClient(leaderDir, "test_index"), remote);
+ Indexer leaderIndexer = new Indexer(leaderManager, registry);
+ leaderIndexer.initialize();
+ leaderIndexer.addDocuments(List.of(
+ MetastoreTableMapper.fromTable(orders, mapping),
+ MetastoreTableMapper.fromTable(customers, mapping)));
+ leaderIndexer.flush(200L, true);
+ leaderIndexer.syncBackup();
+ leaderIndexer.close();
+ leaderManager.close();
+
+ IMetaStoreClient client = mock(IMetaStoreClient.class);
+ MetastoreBootstrapMocks.stubCatchUp(client);
+
+ try (IndexerFixture fixture = IndexerFixture.create(conf, remote)) {
+ try (MetastoreIndexer metastoreIndexer =
+ new MetastoreIndexer(conf, fixture.indexManager, fixture.indexer, client)) {
+ verify(client, never()).getAllDatabases();
+ assertEquals(200L, fixture.indexManager.readLocalManifest().orElseThrow().lastEventId());
+ assertEquals(2, fixture.indexer.writer().getDocStats().numDocs);
+ }
+ }
+ }
+
+ private static Configuration indexerConf(boolean leader) {
+ Configuration conf = new Configuration(false);
+ conf.setBoolean(IndexStateConfig.MEMORY, true);
+ conf.set(IndexConfig.INDEX_NAME, "test_index");
+ conf.set(InferenceConfig.MODEL_NAME, InMemorySearchFixture.MODEL_NAME);
+ conf.setInt(IndexConfig.BOOTSTRAP_FETCH_THREADS, 1);
+ conf.setInt(IndexConfig.BOOTSTRAP_QUEUE_DEPTH, 4);
+ conf.setLong(IndexConfig.BOOTSTRAP_PROGRESS_INTERVAL_MS, Long.MAX_VALUE);
+ conf.setLong(IndexConfig.EVENT_FAILURE_BACKOFF_MS, 0L);
+ conf.setLong(IndexConfig.FLUSH_INTERVAL_MS, 60_000L);
+ conf.setLong(IndexConfig.INDEX_SYNC_INTERVAL, 60_000L);
+ conf.setInt(SearchConfig.BAYESIAN_SAMPLES, 5);
+ conf.setInt(SearchConfig.BAYESIAN_TOKENS_PER_QUERY, 2);
+ conf.setLong(SearchConfig.BAYESIAN_SEED, 1L);
+ MetastoreConf.setVar(conf, MetastoreConf.ConfVars.EVENT_MESSAGE_FACTORY,
+ JSONMessageEncoder.class.getName());
+ TestLeaderElection election = new TestLeaderElection(leader);
+ MetastoreCluster.injectElection(conf, ELECTION, election, "mutex");
+ return conf;
+ }
+
+ private static final class IndexerFixture implements AutoCloseable {
+ IndexManager indexManager;
+ Indexer indexer;
+ final EmbedModelRegistry modelRegistry;
+
+ private IndexerFixture(
+ IndexManager indexManager, Indexer indexer, EmbedModelRegistry modelRegistry) {
+ this.indexManager = indexManager;
+ this.indexer = indexer;
+ this.modelRegistry = modelRegistry;
+ }
+
+ static IndexerFixture create(Configuration conf, InMemoryIndexStateClient remote)
+ throws Exception {
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping(
+ "test_index", InMemorySearchFixture.MODEL_NAME, conf);
+ EmbedModelRegistry registry = new EmbedModelRegistry(
+ Map.of(InMemorySearchFixture.MODEL_NAME, new StubEmbedModel(InMemorySearchFixture.MODEL_NAME)));
+ ByteBuffersDirectory directory = new ByteBuffersDirectory();
+ LocalStateClient local = new LocalStateClient(directory, "test_index");
+ IndexManager indexManager = remote == null
+ ? new IndexManager(mapping, directory, local, null)
+ : new IndexManager(mapping, directory, local, remote);
+ Indexer indexer = new Indexer(indexManager, registry);
+ return new IndexerFixture(indexManager, indexer, registry);
+ }
+
+ @Override
+ public void close() throws Exception {
+ indexer.close();
+ indexManager.close();
+ modelRegistry.close();
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestMetastoreTableMapper.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestMetastoreTableMapper.java
new file mode 100644
index 000000000000..c6e0b79ccb9f
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestMetastoreTableMapper.java
@@ -0,0 +1,370 @@
+/*
+ * 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.hive.search.metastore;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hadoop.hive.metastore.api.FieldSchema;
+import org.apache.hadoop.hive.metastore.api.StorageDescriptor;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hive.search.index.IndexManager;
+import org.apache.hive.search.index.Indexer;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.mapping.field.Field;
+import org.apache.hive.search.mapping.field.IdField;
+import org.apache.hive.search.mapping.field.TextField;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.index.MultiTerms;
+import org.apache.lucene.store.ByteBuffersDirectory;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestMetastoreTableMapper {
+
+ @Test
+ public void tableIdIncludesCatalogDbAndTable() {
+ assertEquals("hive.default.orders",
+ MetastoreTableMapper.tableId("hive", "default", "orders"));
+ assertEquals("hive.default.orders",
+ MetastoreTableMapper.tableId(TableName.fromString("default.orders", "hive", "default")));
+ }
+
+ @Test
+ public void fromTableBuildsSearchTextAndStoredFields() throws Exception {
+ Configuration conf = new Configuration(false);
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping("hive_tables", "bge-small", conf);
+
+ Table table = new Table();
+ table.setCatName("hive");
+ table.setDbName("sales");
+ table.setTableName("orders");
+ table.setOwner("alice");
+ table.setTableType("MANAGED_TABLE");
+ table.setSd(new StorageDescriptor());
+ table.getSd().setLocation("hdfs://warehouse/orders");
+ table.getSd().setCols(List.of(
+ new FieldSchema("id", "bigint", "order id"),
+ new FieldSchema("amount", "double", null)));
+ Map params = new HashMap<>();
+ params.put("comment", "daily orders");
+ table.setParameters(params);
+
+ TableDocument document = MetastoreTableMapper.fromTable(table, mapping);
+ document = withSearchTextEmbedding(document, mapping, new float[] {0.1f, 0.2f, 0.3f});
+ assertEquals("hive.sales.orders", document.idField().value());
+
+ List luceneDocs = document.toDocuments();
+ assertEquals(1, luceneDocs.size());
+ Document luceneDoc = luceneDocs.get(0);
+ assertTrue(luceneDoc.getFields().size() >= 10);
+ assertTrue(luceneDoc.get("_id").contains("hive.sales.orders"));
+ assertEquals("sales", luceneDoc.get(MetastoreTableMapper.FIELD_DB));
+ assertEquals("orders", luceneDoc.get(MetastoreTableMapper.FIELD_TABLE));
+ assertTrue(hasIndexedField(luceneDoc, MetastoreTableMapper.FIELD_TABLE + ".filter"));
+ String searchText = luceneDoc.get(MetastoreTableMapper.FIELD_SEARCH_TEXT);
+ assertTrue(searchText.contains("orders"));
+ assertTrue(searchText.contains("daily orders"));
+ assertTrue(searchText.contains("id order id"));
+ assertFalse(searchText.contains("amount"));
+ assertFalse(searchText.contains("hdfs://"));
+ assertFalse(searchText.contains("MANAGED_TABLE"));
+ assertFalse(searchText.contains("alice"));
+ assertEquals("id bigint order id; amount double", luceneDoc.get(MetastoreTableMapper.FIELD_COLUMNS));
+ assertEquals("id amount", luceneDoc.get(MetastoreTableMapper.FIELD_COLUMN_NAMES));
+ assertEquals("order id", luceneDoc.get(MetastoreTableMapper.FIELD_COLUMN_COMMENTS));
+ }
+
+ @Test
+ public void columnSearchFieldsSplitNamesAndComments() throws Exception {
+ Configuration conf = new Configuration(false);
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping("hive_tables", "bge-small", conf);
+
+ Table table = new Table();
+ table.setCatName("hive");
+ table.setDbName("sales");
+ table.setTableName("orders");
+ table.setSd(new StorageDescriptor());
+ table.getSd().setCols(List.of(
+ new FieldSchema("id", "bigint", "order id"),
+ new FieldSchema("amount", "double", null),
+ new FieldSchema("status", "string", "fulfillment status")));
+
+ TableDocument document = MetastoreTableMapper.fromTable(table, mapping);
+ assertEquals("id amount status", fieldValue(document, MetastoreTableMapper.FIELD_COLUMN_NAMES));
+ assertEquals("order id; fulfillment status",
+ fieldValue(document, MetastoreTableMapper.FIELD_COLUMN_COMMENTS));
+ }
+
+ @Test
+ public void searchTextIncludesOnlyCommentedColumns() throws Exception {
+ Configuration conf = new Configuration(false);
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping("hive_tables", "bge-small", conf);
+
+ Table table = new Table();
+ table.setCatName("hive");
+ table.setDbName("sales");
+ table.setTableName("orders");
+ table.setSd(new StorageDescriptor());
+ table.getSd().setCols(List.of(
+ new FieldSchema("id", "bigint", "order id"),
+ new FieldSchema("amount", "double", null),
+ new FieldSchema("status", "string", "fulfillment status")));
+
+ TableDocument document = MetastoreTableMapper.fromTable(table, mapping);
+ String searchText = fieldValue(document, MetastoreTableMapper.FIELD_SEARCH_TEXT);
+ String storedColumns = fieldValue(document, MetastoreTableMapper.FIELD_COLUMNS);
+
+ assertTrue(searchText.contains("id order id"));
+ assertTrue(searchText.contains("status fulfillment status"));
+ assertFalse(searchText.contains("amount"));
+ assertTrue(storedColumns.contains("amount double"));
+ }
+
+ @Test
+ public void searchTextCapsWideTables() throws Exception {
+ Configuration conf = new Configuration(false);
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping("hive_tables", "bge-small", conf);
+
+ Table table = new Table();
+ table.setCatName("hive");
+ table.setDbName("wide");
+ table.setTableName("events");
+ table.setSd(new StorageDescriptor());
+ List cols = new ArrayList<>();
+ int maxCols = MetastoreTableMapper.MAX_SEARCH_COLUMNS;
+ for (int i = 0; i < maxCols + 5; i++) {
+ cols.add(new FieldSchema("col" + i, "string", "comment " + i));
+ }
+ table.getSd().setCols(cols);
+
+ TableDocument document = MetastoreTableMapper.fromTable(table, mapping);
+ String searchText = fieldValue(document, MetastoreTableMapper.FIELD_SEARCH_TEXT);
+ String storedColumns = fieldValue(document, MetastoreTableMapper.FIELD_COLUMNS);
+
+ assertTrue(searchText.contains("col0 comment 0"));
+ assertTrue(searchText.contains("col" + (maxCols - 1) + " comment " + (maxCols - 1)));
+ assertFalse(searchText.contains("; col" + maxCols + " comment " + maxCols + ";"));
+ assertFalse(searchText.endsWith("; col" + maxCols + " comment " + maxCols));
+ assertTrue(searchText.contains("(+5 more)"));
+ assertTrue(storedColumns.contains("col" + (maxCols + 4) + " string comment " + (maxCols + 4)));
+ assertFalse(storedColumns.contains("(+5 more)"));
+ }
+
+ @Test
+ public void embedDocumentsPreservesLexicalFields() throws Exception {
+ Configuration conf = new Configuration(false);
+ conf.setBoolean(org.apache.hive.search.config.IndexStateConfig.MEMORY, true);
+ conf.set(org.apache.hive.search.config.InferenceConfig.MODEL_NAME, "stub-model");
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping("hive_tables", "stub-model", conf);
+ IndexManager indexManager = IndexManager.open(mapping, conf);
+ EmbedModelRegistry registry = new EmbedModelRegistry(
+ java.util.Map.of("stub-model", new org.apache.hive.search.testutil.StubEmbedModel("stub-model")));
+ Indexer indexer = new Indexer(indexManager, registry);
+ indexer.initialize();
+
+ Table table = sampleTable();
+ Map params = new HashMap<>();
+ params.put("comment", "daily sales orders");
+ table.setParameters(params);
+
+ TableDocument doc = MetastoreTableMapper.fromTable(table, mapping);
+ TableDocument embedded = indexer.embedDocuments(java.util.List.of(doc)).get(0);
+ java.util.Set fieldNames = new java.util.HashSet<>();
+ for (Field field : embedded.fields()) {
+ if (field instanceof TextField textField) {
+ fieldNames.add(textField.name());
+ }
+ }
+ assertTrue(fieldNames.contains(MetastoreTableMapper.FIELD_COMMENT));
+ assertTrue(fieldNames.contains(MetastoreTableMapper.FIELD_SEARCH_TEXT));
+
+ ByteBuffersDirectory directory = new ByteBuffersDirectory();
+ try (IndexWriter writer = new IndexWriter(directory, new IndexWriterConfig(mapping.analyzer()))) {
+ writer.addDocuments(embedded.toDocuments());
+ }
+ try (DirectoryReader reader = DirectoryReader.open(directory)) {
+ assertNotNull(MultiTerms.getTerms(reader, MetastoreTableMapper.FIELD_COMMENT));
+ }
+ indexer.close();
+ indexManager.close();
+ registry.close();
+ }
+
+ @Test
+ public void lexicalFieldsAreIndexedForKeywordSearch() throws Exception {
+ Configuration conf = new Configuration(false);
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping("hive_tables", "bge-small", conf);
+
+ Table table = new Table();
+ table.setCatName("hive");
+ table.setDbName("sales");
+ table.setTableName("orders");
+ table.setSd(new StorageDescriptor());
+ Map params = new HashMap<>();
+ params.put("comment", "daily sales orders");
+ table.setParameters(params);
+
+ TableDocument document = MetastoreTableMapper.fromTable(table, mapping);
+ document = withSearchTextEmbedding(document, mapping, new float[] {0.1f, 0.2f, 0.3f});
+ ByteBuffersDirectory directory = new ByteBuffersDirectory();
+ try (IndexWriter writer = new IndexWriter(directory, new IndexWriterConfig(mapping.analyzer()))) {
+ writer.addDocuments(document.toDocuments());
+ }
+ try (DirectoryReader reader = DirectoryReader.open(directory)) {
+ assertNotNull(MultiTerms.getTerms(reader, MetastoreTableMapper.FIELD_COMMENT));
+ assertNotNull(MultiTerms.getTerms(reader, MetastoreTableMapper.FIELD_TABLE));
+ assertNotNull(MultiTerms.getTerms(reader, MetastoreTableMapper.FIELD_TABLE + ".filter"));
+ }
+ }
+
+ @Test
+ public void semanticFieldRequiresEmbedding() throws Exception {
+ Configuration conf = new Configuration(false);
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping("hive_tables", "bge-small", conf);
+ TableDocument document = MetastoreTableMapper.fromTable(sampleTable(), mapping);
+ document.appendField(new TextField(MetastoreTableMapper.FIELD_SEARCH_TEXT, "sales data"));
+ try {
+ document.toDocuments();
+ org.junit.Assert.fail("expected semantic field without embedding to fail");
+ } catch (org.apache.hive.search.exception.IndexException expected) {
+ assertTrue(expected.getMessage().contains("requires embedding"));
+ }
+ }
+
+ @Test
+ public void hasIndexedFieldsChangedDetectsIndexedAlterations() {
+ Table before = sampleIndexedTable();
+ Table after = copyTable(before);
+ assertFalse(MetastoreTableMapper.hasIndexedFieldsChanged(before, after));
+
+ after.getParameters().put("transient_lastDdlTime", "123");
+ assertFalse(MetastoreTableMapper.hasIndexedFieldsChanged(before, after));
+
+ after = copyTable(before);
+ after.getParameters().put("comment", "updated comment");
+ assertTrue(MetastoreTableMapper.hasIndexedFieldsChanged(before, after));
+
+ after = copyTable(before);
+ after.setOwner("bob");
+ assertTrue(MetastoreTableMapper.hasIndexedFieldsChanged(before, after));
+
+ after = copyTable(before);
+ after.setTableName("invoices");
+ assertTrue(MetastoreTableMapper.hasIndexedFieldsChanged(before, after));
+
+ after = copyTable(before);
+ after.getSd().getCols().get(0).setComment("new column comment");
+ assertTrue(MetastoreTableMapper.hasIndexedFieldsChanged(before, after));
+
+ after = copyTable(before);
+ after.getSd().getCols().get(1).setType("int");
+ assertTrue(MetastoreTableMapper.hasIndexedFieldsChanged(before, after));
+ }
+
+ private static Table sampleIndexedTable() {
+ Table table = new Table();
+ table.setCatName("hive");
+ table.setDbName("sales");
+ table.setTableName("orders");
+ table.setOwner("alice");
+ table.setTableType("MANAGED_TABLE");
+ table.setSd(new StorageDescriptor());
+ table.getSd().setLocation("hdfs://warehouse/orders");
+ table.getSd().setCols(List.of(
+ new FieldSchema("id", "bigint", "order id"),
+ new FieldSchema("amount", "double", null)));
+ Map params = new HashMap<>();
+ params.put("comment", "daily orders");
+ table.setParameters(params);
+ return table;
+ }
+
+ private static Table copyTable(Table source) {
+ Table copy = new Table(source);
+ copy.setSd(new StorageDescriptor(source.getSd()));
+ List cols = new ArrayList<>();
+ for (FieldSchema column : source.getSd().getCols()) {
+ cols.add(new FieldSchema(column));
+ }
+ copy.getSd().setCols(cols);
+ copy.setParameters(new HashMap<>(source.getParameters()));
+ return copy;
+ }
+
+ private static Table sampleTable() {
+ Table table = new Table();
+ table.setCatName("hive");
+ table.setDbName("default");
+ table.setTableName("t");
+ table.setSd(new StorageDescriptor());
+ return table;
+ }
+
+ private static TableDocument withSearchTextEmbedding(
+ TableDocument document, IndexMapping mapping, float[] embedding) {
+ java.util.List fields = new java.util.ArrayList<>();
+ for (Field field : document.fields()) {
+ if (field instanceof IdField) {
+ continue;
+ }
+ if (field instanceof TextField textField
+ && MetastoreTableMapper.FIELD_SEARCH_TEXT.equals(textField.name())) {
+ fields.add(textField.withEmbedding(embedding));
+ } else {
+ fields.add(field);
+ }
+ }
+ return new TableDocument(document.idField(), fields, mapping);
+ }
+
+ private static boolean hasIndexedField(Document document, String fieldName) {
+ for (IndexableField field : document.getFields()) {
+ if (fieldName.equals(field.name())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String fieldValue(TableDocument document, String fieldName) {
+ for (Field field : document.fields()) {
+ if (field instanceof TextField textField && fieldName.equals(textField.name())) {
+ return textField.value();
+ }
+ }
+ throw new AssertionError("missing field: " + fieldName);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestRealMetastoreSearchFailureRecovery.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestRealMetastoreSearchFailureRecovery.java
new file mode 100644
index 000000000000..9ae3324c3876
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestRealMetastoreSearchFailureRecovery.java
@@ -0,0 +1,113 @@
+/*
+ * 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.hive.search.metastore;
+
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.exception.IndexNotHealthyException;
+import org.apache.hive.search.search.TableSearchResult;
+import org.apache.hive.search.testutil.InMemoryIndexStateClient;
+import org.apache.hive.search.testutil.RealMetastoreServer;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestRealMetastoreSearchFailureRecovery {
+
+ private static RealMetastoreServer metastore;
+
+ @BeforeClass
+ public static void startMetastore() throws Exception {
+ metastore = RealMetastoreServer.start();
+ }
+
+ @AfterClass
+ public static void stopMetastore() throws Exception {
+ if (metastore != null) {
+ metastore.close();
+ }
+ }
+
+ @Test
+ public void restoresIndexFromRemoteBackupAfterLocalLoss() throws Exception {
+ String db = RealMetastoreSearchSession.uniqueDbName("restore");
+ metastore.createDatabase(db);
+ metastore.createTable(db, "orders", "daily sales orders");
+
+ InMemoryIndexStateClient remote = RealMetastoreSearchSession.newSharedRemoteBackup();
+ try (RealMetastoreSearchSession leader = RealMetastoreSearchSession.open(metastore, remote)) {
+ leader.waitUntilSearchable("sales", 5);
+ leader.syncBackup();
+ assertTrue(remote.readManifest().isPresent());
+ }
+
+ try (RealMetastoreSearchSession restored = RealMetastoreSearchSession.open(metastore, remote)) {
+ restored.refreshSearcher();
+ TableSearchResult result = restored.searchMatch("sales", 5);
+ assertFalse(result.hits().isEmpty());
+ assertTrue(result.hits().stream().anyMatch(hit -> hit.table().getTable().contains("orders")));
+ }
+ }
+
+ @Test
+ public void recoversIncrementalUpdatesAfterRestore() throws Exception {
+ String db = RealMetastoreSearchSession.uniqueDbName("restore_inc");
+ metastore.createDatabase(db);
+ metastore.createTable(db, "orders", "daily sales orders");
+
+ InMemoryIndexStateClient remote = RealMetastoreSearchSession.newSharedRemoteBackup();
+ try (RealMetastoreSearchSession leader = RealMetastoreSearchSession.open(metastore, remote)) {
+ leader.waitUntilSearchable("sales", 5);
+ leader.syncBackup();
+ }
+
+ try (RealMetastoreSearchSession restored = RealMetastoreSearchSession.open(metastore, remote)) {
+ restored.waitUntilSearchable("sales", 5);
+ metastore.createTable(db, "customers", "customer master records");
+ restored.waitUntilSearchable("customer", 5);
+ assertEquals(2, restored.searchMatch("master", 10).hits().size());
+ }
+ }
+
+ @Test
+ public void skipPoisonEventAllowsIndexingToContinue() throws Exception {
+ String db = RealMetastoreSearchSession.uniqueDbName("poison");
+ metastore.createDatabase(db);
+ metastore.createTable(db, "orders", "daily sales orders");
+
+ try (RealMetastoreSearchSession session = RealMetastoreSearchSession.open(metastore)) {
+ session.waitUntilSearchable("sales", 5);
+ session.indexManager().notifyIndexState(false,
+ new IndexNotHealthyException("simulated notification failure"));
+ assertThrows(IndexNotHealthyException.class, session.indexManager()::checkIndexState);
+
+ session.indexManager().notifyIndexState(true);
+ session.indexManager().checkIndexState();
+
+ metastore.createTable(db, "customers", "customer master records");
+ session.waitUntilSearchable("customer", 5);
+ assertFalse(session.searchMatch("customer", 5).hits().isEmpty());
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestRealMetastoreSearchHybrid.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestRealMetastoreSearchHybrid.java
new file mode 100644
index 000000000000..0926de8db344
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestRealMetastoreSearchHybrid.java
@@ -0,0 +1,98 @@
+/*
+ * 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.hive.search.metastore;
+
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.search.TableSearchResult;
+import org.apache.hive.search.testutil.RealMetastoreServer;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestRealMetastoreSearchHybrid {
+
+ private static RealMetastoreServer metastore;
+
+ @BeforeClass
+ public static void startMetastore() throws Exception {
+ metastore = RealMetastoreServer.start();
+ }
+
+ @AfterClass
+ public static void stopMetastore() throws Exception {
+ if (metastore != null) {
+ metastore.close();
+ }
+ }
+
+ @Test
+ public void keywordSearchFindsMatchingTable() throws Exception {
+ String db = RealMetastoreSearchSession.uniqueDbName("keyword");
+ metastore.createDatabase(db);
+ metastore.createTable(db, "orders", "daily sales orders");
+ metastore.createTable(db, "inventory", "spare parts catalog");
+
+ try (RealMetastoreSearchSession session = RealMetastoreSearchSession.open(metastore)) {
+ session.waitUntilSearchable("sales", 5);
+ TableSearchResult result = session.searchMatch("sales", 5);
+ assertFalse(result.hits().isEmpty());
+ assertTrue(result.hits().stream().anyMatch(hit -> hit.table().getTable().contains("orders")));
+ assertTrue(session.searchMatch("parts", 5).hits().stream()
+ .anyMatch(hit -> hit.table().getTable().contains("inventory")));
+ }
+ }
+
+ @Test
+ public void hybridSearchFindsSemanticallyNamedTable() throws Exception {
+ String db = RealMetastoreSearchSession.uniqueDbName("hybrid");
+ metastore.createDatabase(db);
+ metastore.createTable(db, "orders", "revenue analytics dashboard");
+ metastore.createTable(db, "parts", "spare parts catalog");
+
+ try (RealMetastoreSearchSession session = RealMetastoreSearchSession.open(metastore)) {
+ session.waitUntilSearchable("revenue", 5);
+ TableSearchResult searchResult = session.searchHybrid("revenue analytics", 5);
+ assertFalse(searchResult.hits().isEmpty());
+ assertTrue(searchResult.hits().stream().anyMatch(hit -> hit.table().getTable().contains("orders")));
+ }
+ }
+
+ @Test
+ public void hybridSearchRanksKeywordMatchHigherForExactToken() throws Exception {
+ String db = RealMetastoreSearchSession.uniqueDbName("hybrid_rank");
+ metastore.createDatabase(db);
+ metastore.createTable(db, "orders", "sales orders table");
+ metastore.createTable(db, "metrics", "revenue analytics dashboard");
+
+ try (RealMetastoreSearchSession session = RealMetastoreSearchSession.open(metastore)) {
+ session.waitUntilSearchable("sales", 5);
+ TableSearchResult result = session.searchMatch("sales", 5);
+ assertFalse(result.hits().isEmpty());
+ assertTrue(result.hits().get(0).table().getTable().contains("orders"));
+
+ result = session.searchHybrid("revenue analytics", 5);
+ assertFalse(result.hits().isEmpty());
+ assertTrue(result.hits().stream().anyMatch(hit -> hit.table().getTable().contains("metrics")));
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestRealMetastoreSearchIntegration.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestRealMetastoreSearchIntegration.java
new file mode 100644
index 000000000000..0c2a467ec7db
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/metastore/TestRealMetastoreSearchIntegration.java
@@ -0,0 +1,96 @@
+/*
+ * 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.hive.search.metastore;
+
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.testutil.RealMetastoreServer;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestRealMetastoreSearchIntegration {
+
+ private static RealMetastoreServer metastore;
+
+ @BeforeClass
+ public static void startMetastore() throws Exception {
+ metastore = RealMetastoreServer.start();
+ }
+
+ @AfterClass
+ public static void stopMetastore() throws Exception {
+ if (metastore != null) {
+ metastore.close();
+ }
+ }
+
+ @Test
+ public void bootstrapIndexesExistingTables() throws Exception {
+ String db = RealMetastoreSearchSession.uniqueDbName("bootstrap");
+ metastore.createDatabase(db);
+ metastore.createTable(db, "orders", "daily sales orders");
+ metastore.createTable(db, "customers", "customer master data");
+
+ try (RealMetastoreSearchSession session = RealMetastoreSearchSession.open(metastore)) {
+ session.refreshSearcher();
+ assertFalse(session.searchMatch("orders", 10).hits().isEmpty());
+ assertFalse(session.searchMatch("customers", 10).hits().isEmpty());
+ }
+ }
+
+ @Test
+ public void createAndDropTableUpdatesSearchIndex() throws Exception {
+ String db = RealMetastoreSearchSession.uniqueDbName("incremental");
+ metastore.createDatabase(db);
+
+ try (RealMetastoreSearchSession session = RealMetastoreSearchSession.open(metastore)) {
+ metastore.createTable(db, "orders", "daily sales orders");
+ session.waitUntilSearchable("sales", 5);
+ assertFalse(session.searchMatch("sales", 5).hits().isEmpty());
+
+ metastore.dropTable(db, "orders");
+ session.waitUntilNotSearchable("sales", 5);
+ assertTrue(session.searchMatch("sales", 5).hits().isEmpty());
+ }
+ }
+
+ @Test
+ public void alterTableCommentUpdatesKeywordSearch() throws Exception {
+ String db = RealMetastoreSearchSession.uniqueDbName("alter");
+ metastore.createDatabase(db);
+ metastore.createTable(db, "orders", "old warehouse comment");
+
+ try (RealMetastoreSearchSession session = RealMetastoreSearchSession.open(metastore)) {
+ session.waitUntilSearchable("warehouse", 5);
+ assertTrue(session.searchMatch("revenue", 5).hits().isEmpty());
+
+ metastore.dropTable(db, "orders");
+ metastore.createTable(db, "orders", "revenue analytics dashboard");
+ session.waitUntilSearchable("revenue", 5);
+ assertFalse(session.searchMatch("revenue", 5).hits().isEmpty());
+ }
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/InMemorySearchFixture.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/InMemorySearchFixture.java
new file mode 100644
index 000000000000..3c0878152731
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/InMemorySearchFixture.java
@@ -0,0 +1,171 @@
+/*
+ * 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.hive.search.search;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.api.StorageDescriptor;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hive.search.config.IndexConfig;
+import org.apache.hive.search.config.IndexStateConfig;
+import org.apache.hive.search.config.InferenceConfig;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.hive.search.index.Indexer;
+import org.apache.hive.search.index.IndexManager;
+import org.apache.hive.search.inference.EmbedModelRegistry;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.metastore.MetastoreSchemas;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.apache.hive.search.testutil.IndexMutationApplier;
+import org.apache.hive.search.testutil.StubEmbedModel;
+import org.apache.lucene.search.BayesianScoreEstimator;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.SearcherManager;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** In-memory Lucene index wired with a stub embedding model. */
+public final class InMemorySearchFixture implements AutoCloseable {
+ public static final String MODEL_NAME = "stub-model";
+
+ private final IndexManager indexManager;
+ private final Indexer indexer;
+ private final EmbedModelRegistry modelRegistry;
+ private final SearchConfig searchConfig;
+ private final IndexMutationApplier mutations;
+ private SearcherManager searcherManager;
+ private BayesianScoreEstimator.Parameters bayesianParameters;
+
+ private InMemorySearchFixture(
+ IndexManager indexManager,
+ Indexer indexer,
+ EmbedModelRegistry modelRegistry,
+ SearchConfig searchConfig) {
+ this.indexManager = indexManager;
+ this.indexer = indexer;
+ this.modelRegistry = modelRegistry;
+ this.searchConfig = searchConfig;
+ this.mutations = new IndexMutationApplier(indexManager, indexer);
+ }
+
+ public static InMemorySearchFixture create() throws Exception {
+ Configuration conf = new Configuration(false);
+ conf.setBoolean(IndexStateConfig.MEMORY, true);
+ conf.set(IndexConfig.INDEX_NAME, "test_index");
+ conf.set(InferenceConfig.MODEL_NAME, MODEL_NAME);
+ conf.setInt(SearchConfig.BAYESIAN_SAMPLES, 5);
+ conf.setInt(SearchConfig.BAYESIAN_TOKENS_PER_QUERY, 2);
+ conf.setLong(SearchConfig.BAYESIAN_SEED, 1L);
+
+ IndexMapping mapping = MetastoreSchemas.defaultHiveTablesMapping(
+ "test_index", MODEL_NAME, conf);
+ IndexManager indexManager = IndexManager.open(mapping, conf);
+ EmbedModelRegistry registry =
+ new EmbedModelRegistry(Map.of(MODEL_NAME, new StubEmbedModel(MODEL_NAME)));
+ Indexer indexer = new Indexer(indexManager, registry);
+ indexer.initialize();
+ return new InMemorySearchFixture(indexManager, indexer, registry, new SearchConfig(conf));
+ }
+
+ public IndexMutationApplier mutations() {
+ return mutations;
+ }
+
+ public void commit(long eventId) throws IOException {
+ indexer.flush(eventId, true);
+ refreshSearcher();
+ }
+
+ public void refreshSearcher() throws IOException {
+ if (searcherManager == null) {
+ searcherManager = new SearcherManager(indexer.writer(), null);
+ } else {
+ searcherManager.maybeRefresh();
+ }
+ if (bayesianParameters == null) {
+ IndexSearcher searcher = searcherManager.acquire();
+ try {
+ bayesianParameters = BayesianScoreEstimator.estimate(
+ searcher,
+ MetastoreTableMapper.FIELD_SEARCH_TEXT,
+ searchConfig.getBayesianSamples(),
+ searchConfig.getBayesianTokensPerQuery(),
+ searchConfig.getBayesianSeed());
+ } finally {
+ searcherManager.release(searcher);
+ }
+ }
+ }
+
+ public List searchMatch(String text, int limit) throws Exception {
+ return search(new SearchArgs.Match(text), limit);
+ }
+
+ public List search(SearchArgs args, int limit) throws Exception {
+ if (searcherManager == null || bayesianParameters == null) {
+ throw new IllegalStateException("call commit() before searching");
+ }
+ try (SearchInternal searchIO = new SearchInternal(
+ searcherManager, indexManager, modelRegistry, searchConfig, bayesianParameters)) {
+ TableSearchResult result = searchIO.search(new SearchQuery(
+ args,
+ null, null, limit,
+ List.of(MetastoreTableMapper.FIELD_TABLE, MetastoreTableMapper.FIELD_COMMENT)));
+ return result.hits();
+ }
+ }
+
+ public static Table table(String catalog, String db, String name, String comment) {
+ Table table = new Table();
+ table.setCatName(catalog);
+ table.setDbName(db);
+ table.setTableName(name);
+ table.setOwner("owner");
+ table.setTableType("MANAGED_TABLE");
+ table.setSd(new StorageDescriptor());
+ table.getSd().setLocation("hdfs://warehouse/" + db + "/" + name);
+ Map params = new HashMap<>();
+ params.put("comment", comment);
+ table.setParameters(params);
+ return table;
+ }
+
+ public IndexManager indexManager() {
+ return indexManager;
+ }
+
+ public Indexer indexer() {
+ return indexer;
+ }
+
+ public SearcherManager searcherManager() {
+ return searcherManager;
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (searcherManager != null) {
+ searcherManager.close();
+ }
+ indexer.close();
+ indexManager.close();
+ modelRegistry.close();
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/TestHybridSearch.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/TestHybridSearch.java
new file mode 100644
index 000000000000..29930303b713
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/TestHybridSearch.java
@@ -0,0 +1,51 @@
+/*
+ * 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.hive.search.search;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.config.SearchConfig;
+import org.apache.hive.search.exception.SearchException;
+import org.apache.hive.search.metastore.MetastoreSchemas;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestHybridSearch {
+
+ @Test
+ public void toFusionRequestUsesDefaultsFromSearchConfig() throws SearchException {
+ HybridSearch.ResolvedHybridQuery resolved = new HybridSearch.ResolvedHybridQuery(
+ MetastoreTableMapper.FIELD_SEARCH_TEXT, "sales", null);
+ SearchConfig searchConfig = new SearchConfig(new Configuration(false));
+ SearchInternal.FusionRequest request =
+ HybridSearch.toFusionRequest(resolved, 20, searchConfig);
+
+ assertEquals(20, request.size());
+ assertEquals(2, request.retrievers().size());
+ assertEquals(SearchQuery.Mode.MATCH.name(), request.retrievers().get(0).name());
+ assertTrue(request.retrievers().get(0).query() instanceof SearchArgs.Match);
+ assertEquals("sales", ((SearchArgs.Match) request.retrievers().get(0).query()).queryText());
+ assertEquals(searchConfig.getHybridMatchWeight(), request.retrievers().get(0).weight(), 0.001f);
+ assertEquals(searchConfig.getHybridSemanticWeight(), request.retrievers().get(1).weight(), 0.001f);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/TestSearchArgs.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/TestSearchArgs.java
new file mode 100644
index 000000000000..e5f276096b29
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/TestSearchArgs.java
@@ -0,0 +1,226 @@
+/*
+ * 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.hive.search.search;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.exception.SearchException;
+import org.apache.hive.search.mapping.FieldSchema;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.SearchParams;
+import org.apache.hive.search.metastore.MetastoreSchemas;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+
+@Category(MetastoreUnitTest.class)
+public class TestSearchArgs {
+
+ private static IndexMapping hybridMapping() {
+ Configuration conf = new Configuration(false);
+ return MetastoreSchemas.defaultHiveTablesMapping("hive_tables", "bge-small", conf);
+ }
+
+ @Test
+ public void parseHybridString() throws Exception {
+ SearchArgs.Hybrid hybrid =
+ (SearchArgs.Hybrid) SearchArgs.fromBody("sales revenue", SearchQuery.Mode.HYBRID);
+ assertEquals("sales revenue", hybrid.queryText());
+ assertNull(hybrid.field());
+ }
+
+ @Test
+ public void parseHybridFieldAndQueryObject() throws Exception {
+ Map body = Map.of(
+ "field", MetastoreTableMapper.FIELD_SEARCH_TEXT,
+ "query", "orders");
+ SearchArgs.Hybrid hybrid =
+ (SearchArgs.Hybrid) SearchArgs.fromBody(body, SearchQuery.Mode.HYBRID);
+ assertEquals(MetastoreTableMapper.FIELD_SEARCH_TEXT, hybrid.field());
+ assertEquals("orders", hybrid.queryText());
+ }
+
+ @Test
+ public void parseHybridSingleFieldMap() throws Exception {
+ Map body = Map.of(MetastoreTableMapper.FIELD_SEARCH_TEXT, "inventory");
+ SearchArgs.Hybrid hybrid =
+ (SearchArgs.Hybrid) SearchArgs.fromBody(body, SearchQuery.Mode.HYBRID);
+ assertEquals("inventory", hybrid.queryText());
+ }
+
+ @Test
+ public void parseHybridCustomWeights() throws Exception {
+ Map body = Map.of(
+ "field", MetastoreTableMapper.FIELD_SEARCH_TEXT,
+ "query", "metrics",
+ "match_weight", 0.7,
+ "semantic_weight", 0.3);
+ SearchArgs.Hybrid hybrid =
+ (SearchArgs.Hybrid) SearchArgs.fromBody(body, SearchQuery.Mode.HYBRID);
+ assertEquals(0.3f, hybrid.semanticWeight(), 0.001f);
+ }
+
+ @Test
+ public void parseHybridRejectsInvalidWeights() {
+ Map body = Map.of(
+ "query", "metrics",
+ "match_weight", 0.9,
+ "semantic_weight", 0.9);
+ assertThrows(SearchException.class,
+ () -> SearchArgs.fromBody(body, SearchQuery.Mode.HYBRID));
+ }
+
+ @Test
+ public void parseSemanticString() throws Exception {
+ SearchArgs.Semantic semantic =
+ (SearchArgs.Semantic) SearchArgs.fromBody("sales revenue", SearchQuery.Mode.SEMANTIC);
+ assertEquals("sales revenue", semantic.queryText());
+ assertNull(semantic.field());
+ }
+
+ @Test
+ public void parseSemanticFieldAndQueryObject() throws Exception {
+ Map body = Map.of(
+ "field", MetastoreTableMapper.FIELD_SEARCH_TEXT,
+ "query", "orders");
+ SearchArgs.Semantic semantic =
+ (SearchArgs.Semantic) SearchArgs.fromBody(body, SearchQuery.Mode.SEMANTIC);
+ assertEquals(MetastoreTableMapper.FIELD_SEARCH_TEXT, semantic.field());
+ assertEquals("orders", semantic.queryText());
+ }
+
+ @Test
+ public void parseSemanticSingleFieldMap() throws Exception {
+ Map body = Map.of(MetastoreTableMapper.FIELD_SEARCH_TEXT, "inventory");
+ SearchArgs.Semantic semantic =
+ (SearchArgs.Semantic) SearchArgs.fromBody(body, SearchQuery.Mode.SEMANTIC);
+ assertEquals(MetastoreTableMapper.FIELD_SEARCH_TEXT, semantic.field());
+ assertEquals("inventory", semantic.queryText());
+ }
+
+ @Test
+ public void resolveHybridUsesSoleHybridField() throws Exception {
+ SearchArgs.Hybrid args =
+ (SearchArgs.Hybrid) SearchArgs.fromBody("sales revenue", SearchQuery.Mode.HYBRID);
+ HybridSearch.ResolvedHybridQuery resolved = HybridSearch.resolve(args, hybridMapping());
+ assertEquals(MetastoreTableMapper.FIELD_SEARCH_TEXT, resolved.field());
+ assertEquals("sales revenue", resolved.queryText());
+ }
+
+ @Test
+ public void resolveHybridRejectsNonHybridField() {
+ SearchArgs.Hybrid args = new SearchArgs.Hybrid(
+ "t1", MetastoreTableMapper.FIELD_TABLE, null, null);
+ assertThrows(SearchException.class, () -> HybridSearch.resolve(args, hybridMapping()));
+ }
+
+ @Test
+ public void resolveHybridRequiresFieldWhenMultipleHybridFieldsExist() throws Exception {
+ Configuration conf = new Configuration(false);
+ Map fields = new LinkedHashMap<>();
+ fields.put(
+ "field_a",
+ new FieldSchema.TextFieldSchema(
+ "field_a", new SearchParams(true, "model-a", SearchParams.VectorDistance.COSINE)));
+ fields.put(
+ "field_b",
+ new FieldSchema.TextFieldSchema(
+ "field_b", new SearchParams(true, "model-b", SearchParams.VectorDistance.COSINE)));
+ IndexMapping mapping = new IndexMapping("idx", conf, fields);
+ SearchArgs.Hybrid args =
+ (SearchArgs.Hybrid) SearchArgs.fromBody("query", SearchQuery.Mode.HYBRID);
+
+ assertThrows(SearchException.class, () -> HybridSearch.resolve(args, mapping));
+ }
+
+ @Test
+ public void resolveSemanticUsesSoleSemanticField() throws Exception {
+ SearchArgs.Semantic args =
+ (SearchArgs.Semantic) SearchArgs.fromBody("sales revenue", SearchQuery.Mode.SEMANTIC);
+ SemanticSearch.ResolvedSemanticQuery resolved =
+ SemanticSearch.resolve(args, hybridMapping());
+ assertEquals(MetastoreTableMapper.FIELD_SEARCH_TEXT, resolved.field());
+ assertEquals("sales revenue", resolved.queryText());
+ }
+
+ @Test
+ public void resolveSemanticRejectsNonSemanticField() {
+ SearchArgs.Semantic args = new SearchArgs.Semantic("t1", MetastoreTableMapper.FIELD_TABLE);
+ assertThrows(SearchException.class, () -> SemanticSearch.resolve(args, hybridMapping()));
+ }
+
+ @Test
+ public void serializeMatchRoundTrip() throws Exception {
+ assertRoundTrip(new SearchArgs.Match("orders"), SearchQuery.Mode.MATCH);
+ assertEquals(Map.of("query", "orders"), SearchArgs.toBody(new SearchArgs.Match("orders")));
+ }
+
+ @Test
+ public void serializeSemanticRoundTrip() throws Exception {
+ assertRoundTrip(new SearchArgs.Semantic("sales", null), SearchQuery.Mode.SEMANTIC);
+ assertRoundTrip(
+ new SearchArgs.Semantic("orders", MetastoreTableMapper.FIELD_SEARCH_TEXT),
+ SearchQuery.Mode.SEMANTIC);
+ assertEquals(
+ Map.of(
+ "query", "orders",
+ "field", MetastoreTableMapper.FIELD_SEARCH_TEXT),
+ SearchArgs.toBody(
+ new SearchArgs.Semantic("orders", MetastoreTableMapper.FIELD_SEARCH_TEXT)));
+ }
+
+ @Test
+ public void serializeHybridRoundTrip() throws Exception {
+ assertRoundTrip(new SearchArgs.Hybrid("sales", null, null, null), SearchQuery.Mode.HYBRID);
+ assertRoundTrip(
+ new SearchArgs.Hybrid("metrics", MetastoreTableMapper.FIELD_SEARCH_TEXT, null, null),
+ SearchQuery.Mode.HYBRID);
+ assertRoundTrip(
+ new SearchArgs.Hybrid("metrics", null, 0.7f, 0.3f),
+ SearchQuery.Mode.HYBRID);
+ assertRoundTrip(
+ new SearchArgs.Hybrid("metrics", MetastoreTableMapper.FIELD_SEARCH_TEXT, 0.7f, 0.3f),
+ SearchQuery.Mode.HYBRID);
+ }
+
+ @Test
+ public void serializeToQueryBodyRoundTrip() throws Exception {
+ SearchQuery query = SearchQuery.of("sales", SearchQuery.Mode.HYBRID, 10);
+ SearchQuery roundTripped = SearchQuery.fromQueryBody(
+ query.toQueryBody(),
+ query.mode(),
+ query.catalogName(),
+ query.databaseName(),
+ query.limit(),
+ query.returnFields());
+ assertEquals(query.args(), roundTripped.args());
+ assertEquals(query.mode(), roundTripped.mode());
+ }
+
+ private static void assertRoundTrip(SearchArgs args, SearchQuery.Mode mode) throws SearchException {
+ SearchArgs roundTripped = SearchArgs.fromBody(SearchArgs.toBody(args), mode);
+ assertEquals(args, roundTripped);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/TestSearchQuery.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/TestSearchQuery.java
new file mode 100644
index 000000000000..80f341c504ed
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/TestSearchQuery.java
@@ -0,0 +1,69 @@
+/*
+ * 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.hive.search.search;
+
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.exception.SearchException;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+@Category(MetastoreUnitTest.class)
+public class TestSearchQuery {
+
+ @Test
+ public void ofTextDefaultsToHybridMode() throws Exception {
+ SearchQuery query = SearchQuery.of("sales");
+ assertTrue(query.args() instanceof SearchArgs.Hybrid);
+ assertEquals("sales", ((SearchArgs.Hybrid) query.args()).queryText());
+ assertEquals(SearchQuery.Mode.HYBRID, query.mode());
+ assertEquals(0, query.limit());
+ }
+
+ @Test
+ public void ofTextWithModeAndLimit() throws Exception {
+ SearchQuery query = SearchQuery.of("sales", SearchQuery.Mode.SEMANTIC, 25);
+ assertTrue(query.args() instanceof SearchArgs.Semantic);
+ assertEquals(SearchQuery.Mode.SEMANTIC, query.mode());
+ assertEquals(25, query.limit());
+ }
+
+ @Test
+ public void ofTableNameUsesKeywordMode() throws Exception {
+ SearchQuery query = SearchQuery.of("orders", "hive", "default");
+ assertTrue(query.args() instanceof SearchArgs.Match);
+ assertEquals("orders", ((SearchArgs.Match) query.args()).queryText());
+ assertEquals(SearchQuery.Mode.MATCH, query.mode());
+ assertEquals("hive", query.catalogName());
+ assertEquals("default", query.databaseName());
+ }
+
+ @Test
+ public void rejectsEmptyQueryText() {
+ assertThrows(SearchException.class, () -> SearchQuery.of(""));
+ }
+
+ @Test
+ public void rejectsNegativeLimit() {
+ assertThrows(SearchException.class,
+ () -> SearchQuery.of("sales", SearchQuery.Mode.HYBRID, -1));
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/TestSemanticSearch.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/TestSemanticSearch.java
new file mode 100644
index 000000000000..a2cde281ef4b
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/search/TestSemanticSearch.java
@@ -0,0 +1,90 @@
+/*
+ * 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.hive.search.search;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hive.search.exception.SearchException;
+import org.apache.hive.search.mapping.FieldSchema;
+import org.apache.hive.search.mapping.IndexMapping;
+import org.apache.hive.search.mapping.SearchParams;
+import org.apache.hive.search.metastore.MetastoreSchemas;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+@Category(MetastoreUnitTest.class)
+public class TestSemanticSearch {
+
+ private static IndexMapping defaultMapping() {
+ Configuration conf = new Configuration(false);
+ return MetastoreSchemas.defaultHiveTablesMapping("hive_tables", "bge-small", conf);
+ }
+
+ @Test
+ public void resolveStringUsesSoleSemanticField() throws Exception {
+ SemanticSearch.ResolvedSemanticQuery resolved =
+ SemanticSearch.resolve(new SearchArgs.Semantic("sales revenue", null), defaultMapping());
+ assertEquals(MetastoreTableMapper.FIELD_SEARCH_TEXT, resolved.field());
+ assertEquals("sales revenue", resolved.queryText());
+ }
+
+ @Test
+ public void resolveExplicitField() throws Exception {
+ SemanticSearch.ResolvedSemanticQuery resolved = SemanticSearch.resolve(
+ new SearchArgs.Semantic("orders", MetastoreTableMapper.FIELD_SEARCH_TEXT),
+ defaultMapping());
+ assertEquals(MetastoreTableMapper.FIELD_SEARCH_TEXT, resolved.field());
+ assertEquals("orders", resolved.queryText());
+ }
+
+ @Test
+ public void resolveRejectsNonSemanticField() {
+ assertThrows(
+ SearchException.class,
+ () ->
+ SemanticSearch.resolve(
+ new SearchArgs.Semantic("t1", MetastoreTableMapper.FIELD_TABLE),
+ defaultMapping()));
+ }
+
+ @Test
+ public void resolveRequiresFieldWhenMultipleSemanticFieldsExist() {
+ Configuration conf = new Configuration(false);
+ Map fields = new LinkedHashMap<>();
+ fields.put(
+ "field_a",
+ new FieldSchema.TextFieldSchema(
+ "field_a", new SearchParams(true, "model-a", SearchParams.VectorDistance.COSINE)));
+ fields.put(
+ "field_b",
+ new FieldSchema.TextFieldSchema(
+ "field_b", new SearchParams(true, "model-b", SearchParams.VectorDistance.COSINE)));
+ IndexMapping mapping = new IndexMapping("idx", conf, fields);
+
+ assertThrows(
+ SearchException.class,
+ () -> SemanticSearch.resolve(new SearchArgs.Semantic("query", null), mapping));
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/InMemoryIndexStateClient.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/InMemoryIndexStateClient.java
new file mode 100644
index 000000000000..72c5a3d8c8a6
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/InMemoryIndexStateClient.java
@@ -0,0 +1,85 @@
+/*
+ * 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.hive.search.testutil;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.hive.search.exception.IndexIOException;
+import org.apache.hive.search.index.manifest.IndexManifest;
+import org.apache.hive.search.index.store.IndexStateClient;
+
+/** In-memory {@link IndexStateClient} for unit tests. */
+public final class InMemoryIndexStateClient implements IndexStateClient {
+ private final Map files = new HashMap<>();
+ private byte[] stagingManifest;
+
+ @Override
+ public Optional readManifest() throws IOException {
+ byte[] bytes = files.get(IndexManifest.MANIFEST_FILE_NAME);
+ return bytes == null ? Optional.empty() : Optional.of(IndexManifest.fromJson(bytes));
+ }
+
+ @Override
+ public InputStream read(String fileName) throws IOException {
+ byte[] bytes = files.get(fileName);
+ if (bytes == null) {
+ throw new IndexIOException("missing file: " + fileName);
+ }
+ return new ByteArrayInputStream(bytes);
+ }
+
+ @Override
+ public void write(String fileName, InputStream stream) throws IOException {
+ files.put(fileName, stream.readAllBytes());
+ }
+
+ @Override
+ public void delete(String fileName) throws IOException {
+ files.remove(fileName);
+ }
+
+ @Override
+ public Optional readStagingManifest() throws IOException {
+ return stagingManifest == null ? Optional.empty() : Optional.of(IndexManifest.fromJson(stagingManifest));
+ }
+
+ @Override
+ public void writeStagingManifest(IndexManifest manifest) throws IOException {
+ stagingManifest = manifest.toJsonBytes();
+ }
+
+ @Override
+ public void clearStagingManifest() {
+ stagingManifest = null;
+ }
+
+ @Override
+ public void clear() {
+ files.clear();
+ stagingManifest = null;
+ }
+
+ public boolean hasFile(String fileName) {
+ return files.containsKey(fileName);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/IndexMutationApplier.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/IndexMutationApplier.java
new file mode 100644
index 000000000000..c4dca5209737
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/IndexMutationApplier.java
@@ -0,0 +1,82 @@
+/*
+ * 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.hive.search.testutil;
+
+import org.apache.hadoop.hive.common.DatabaseName;
+import org.apache.hadoop.hive.common.TableName;
+import org.apache.hadoop.hive.metastore.api.Table;
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.index.Indexer;
+import org.apache.hive.search.index.IndexManager;
+import org.apache.hive.search.mapping.TableDocument;
+import org.apache.hive.search.metastore.MetastoreEventListener;
+import org.apache.hive.search.metastore.MetastoreTableMapper;
+
+import java.io.IOException;
+import java.util.List;
+
+/** Applies {@link MetastoreEventListener.IndexTask} mutations to an {@link Indexer}. */
+public final class IndexMutationApplier {
+ private final IndexManager indexManager;
+ private final Indexer indexer;
+
+ public IndexMutationApplier(IndexManager indexManager, Indexer indexer) {
+ this.indexManager = indexManager;
+ this.indexer = indexer;
+ }
+
+ public void apply(MetastoreEventListener.IndexTask task)
+ throws IndexException, IOException {
+ if (!task.databasesToDrop.isEmpty()) {
+ indexer.deleteDatabases(task.databasesToDrop.toArray(new DatabaseName[0]));
+ }
+ if (!task.tablesToDrop.isEmpty()) {
+ String[] docIds = task.tablesToDrop.stream()
+ .map(MetastoreTableMapper::tableId)
+ .toList()
+ .toArray(new String[0]);
+ indexer.delete(docIds);
+ }
+ if (!task.tablesToAdd.isEmpty()) {
+ List newDocs = task.tablesToAdd.values().stream()
+ .map(table -> MetastoreTableMapper.fromTable(table, indexManager.mapping()))
+ .toList();
+ indexer.addDocuments(newDocs);
+ }
+ }
+
+ public void addTable(Table table) throws IndexException, IOException {
+ MetastoreEventListener.IndexTask task = new MetastoreEventListener.IndexTask();
+ TableName tableName = new TableName(table.getCatName(), table.getDbName(), table.getTableName());
+ task.tablesToAdd.put(tableName, table);
+ apply(task);
+ }
+
+ public void dropTable(TableName tableName) throws IndexException, IOException {
+ MetastoreEventListener.IndexTask task = new MetastoreEventListener.IndexTask();
+ task.tablesToDrop.add(tableName);
+ apply(task);
+ }
+
+ public void replaceTable(Table before, Table after) throws IndexException, IOException {
+ MetastoreEventListener.IndexTask task = new MetastoreEventListener.IndexTask();
+ task.tablesToDrop.add(new TableName(before.getCatName(), before.getDbName(), before.getTableName()));
+ task.tablesToAdd.put(new TableName(after.getCatName(), after.getDbName(), after.getTableName()), after);
+ apply(task);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/MetastoreBootstrapMocks.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/MetastoreBootstrapMocks.java
new file mode 100644
index 000000000000..b3ef31b0689c
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/MetastoreBootstrapMocks.java
@@ -0,0 +1,85 @@
+/*
+ * 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.hive.search.testutil;
+
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.api.CurrentNotificationEventId;
+import org.apache.hadoop.hive.metastore.api.NotificationEventRequest;
+import org.apache.hadoop.hive.metastore.api.NotificationEventResponse;
+import org.apache.hadoop.hive.metastore.api.Table;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+/** Mockito helpers for bootstrap and indexer initialization tests. */
+public final class MetastoreBootstrapMocks {
+ private MetastoreBootstrapMocks() {}
+
+ public static void stubCatchUp(IMetaStoreClient client) throws Exception {
+ NotificationEventResponse empty = new NotificationEventResponse();
+ empty.setEvents(new ArrayList<>());
+ when(client.getNextNotification(any(NotificationEventRequest.class), eq(false), eq(null)))
+ .thenReturn(empty);
+ }
+
+ public static void stubCurrentNotificationId(IMetaStoreClient client, long eventId)
+ throws Exception {
+ when(client.getCurrentNotificationEventId())
+ .thenReturn(new CurrentNotificationEventId(eventId));
+ }
+
+ public static void stubBootstrapCatalog(IMetaStoreClient client, Table... tables)
+ throws Exception {
+ Map> tablesByDb = new LinkedHashMap<>();
+ for (Table table : tables) {
+ tablesByDb.computeIfAbsent(table.getDbName(), db -> new ArrayList<>()).add(table);
+ }
+ when(client.getAllDatabases()).thenReturn(new ArrayList<>(tablesByDb.keySet()));
+ for (Map.Entry> entry : tablesByDb.entrySet()) {
+ String db = entry.getKey();
+ List tableNames = entry.getValue().stream()
+ .map(Table::getTableName)
+ .collect(Collectors.toList());
+ when(client.getAllTables(db)).thenReturn(tableNames);
+ when(client.getTableObjectsByName(eq(db), anyList())).thenAnswer(invocation -> {
+ @SuppressWarnings("unchecked")
+ List requested = invocation.getArgument(1);
+ return requested.stream()
+ .map(name -> findTable(entry.getValue(), name))
+ .collect(Collectors.toList());
+ });
+ }
+ }
+
+ private static Table findTable(List
tables, String name) {
+ for (Table table : tables) {
+ if (table.getTableName().equals(name)) {
+ return table;
+ }
+ }
+ throw new IllegalArgumentException("Unknown table: " + name);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/RealMetastoreServer.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/RealMetastoreServer.java
new file mode 100644
index 000000000000..c483104a8380
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/RealMetastoreServer.java
@@ -0,0 +1,105 @@
+/*
+ * 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.hive.search.testutil;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.HiveMetaStoreClient;
+import org.apache.hadoop.hive.metastore.IMetaStoreClient;
+import org.apache.hadoop.hive.metastore.MetaStoreTestUtils;
+import org.apache.hadoop.hive.metastore.client.builder.DatabaseBuilder;
+import org.apache.hadoop.hive.metastore.client.builder.TableBuilder;
+import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
+import org.apache.hadoop.hive.metastore.conf.MetastoreConf.ConfVars;
+import org.apache.hadoop.hive.metastore.messaging.json.JSONMessageEncoder;
+import org.apache.hadoop.hive.metastore.security.HadoopThriftAuthBridge;
+import org.apache.hive.hcatalog.listener.DbNotificationListener;
+
+/** Starts an in-process HMS backed by Derby for integration tests. */
+public final class RealMetastoreServer implements AutoCloseable {
+ private static RealMetastoreServer instance;
+
+ private final Configuration conf;
+ private final int port;
+ private final HiveMetaStoreClient client;
+
+ private RealMetastoreServer(Configuration conf, int port, HiveMetaStoreClient client) {
+ this.conf = conf;
+ this.port = port;
+ this.client = client;
+ }
+
+ public static synchronized RealMetastoreServer start() throws Exception {
+ if (instance != null) {
+ return instance;
+ }
+ Configuration conf = MetastoreConf.newMetastoreConf();
+ MetaStoreTestUtils.setConfForStandloneMode(conf);
+ MetastoreConf.setBoolVar(conf, ConfVars.HIVE_IN_TEST, true);
+ MetastoreConf.setBoolVar(conf, ConfVars.CAPABILITY_CHECK, false);
+ MetastoreConf.setVar(conf, ConfVars.TRANSACTIONAL_EVENT_LISTENERS,
+ DbNotificationListener.class.getName());
+ MetastoreConf.setVar(conf, ConfVars.EVENT_MESSAGE_FACTORY, JSONMessageEncoder.class.getName());
+ MetastoreConf.setBoolVar(conf, ConfVars.HIVE_SUPPORT_CONCURRENCY, false);
+ int port = MetaStoreTestUtils.startMetaStoreWithRetry(HadoopThriftAuthBridge.getBridge(), conf);
+ HiveMetaStoreClient client = new HiveMetaStoreClient(conf);
+ instance = new RealMetastoreServer(conf, port, client);
+ return instance;
+ }
+
+ public static synchronized RealMetastoreServer get() {
+ if (instance == null) {
+ throw new IllegalStateException("Call RealMetastoreServer.start() first");
+ }
+ return instance;
+ }
+
+ public Configuration conf() {
+ return new Configuration(conf);
+ }
+
+ public IMetaStoreClient client() {
+ return client;
+ }
+
+ public void createDatabase(String dbName) throws Exception {
+ new DatabaseBuilder().setName(dbName).create(client, conf);
+ }
+
+ public void createTable(String dbName, String tableName, String comment) throws Exception {
+ new TableBuilder()
+ .setDbName(dbName)
+ .setTableName(tableName)
+ .addCol("id", "int")
+ .addTableParam("comment", comment)
+ .create(client, conf);
+ }
+
+ public void dropTable(String dbName, String tableName) throws Exception {
+ client.dropTable(dbName, tableName);
+ }
+
+ @Override
+ public synchronized void close() {
+ if (instance == null) {
+ return;
+ }
+ client.close();
+ MetaStoreTestUtils.close(port);
+ instance = null;
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/StubEmbedModel.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/StubEmbedModel.java
new file mode 100644
index 000000000000..e775fb555dee
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/StubEmbedModel.java
@@ -0,0 +1,85 @@
+/*
+ * 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.hive.search.testutil;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.hive.search.exception.IndexException;
+import org.apache.hive.search.inference.EmbedModel;
+
+/** Deterministic embedding model for tests (no ONNX). */
+public final class StubEmbedModel implements EmbedModel {
+ private static final int DIMENSION = 8;
+
+ private final String name;
+ private final AtomicInteger encodeBatchCalls = new AtomicInteger();
+
+ public StubEmbedModel(String name) {
+ this.name = name;
+ }
+
+ public int encodeBatchCalls() {
+ return encodeBatchCalls.get();
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public float[] embed(TaskType task, String text) {
+ return vector(text + ":" + task.name());
+ }
+
+ @Override
+ public void close() {
+ // no-op
+ }
+
+ private static float[] vector(String text) {
+ byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
+ float[] embedding = new float[DIMENSION];
+ for (int i = 0; i < DIMENSION; i++) {
+ embedding[i] = (bytes[i % bytes.length] & 0xFF) / 255.0f;
+ }
+ float norm = 0f;
+ for (float value : embedding) {
+ norm += value * value;
+ }
+ norm = (float) Math.sqrt(norm);
+ if (norm > 0f) {
+ for (int i = 0; i < embedding.length; i++) {
+ embedding[i] /= norm;
+ }
+ }
+ return embedding;
+ }
+
+ public static float[] queryVector(String text) {
+ return vector(text + ":" + TaskType.QUERY.name());
+ }
+
+ @Override
+ public float[][] embedBatch(TaskType task, String[] texts) throws IndexException {
+ encodeBatchCalls.incrementAndGet();
+ return Arrays.stream(texts).map(text -> embed(task, text)).toArray(float[][]::new);
+ }
+}
diff --git a/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/TestLeaderElection.java b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/TestLeaderElection.java
new file mode 100644
index 000000000000..e9a298d1d598
--- /dev/null
+++ b/standalone-metastore/metastore-search/src/test/java/org/apache/hive/search/testutil/TestLeaderElection.java
@@ -0,0 +1,73 @@
+/*
+ * 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.hive.search.testutil;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.hive.metastore.leader.LeaderElection;
+import org.apache.hadoop.hive.metastore.leader.LeaderException;
+
+/** Fixed leader election for metastore-search unit and integration tests. */
+public final class TestLeaderElection implements LeaderElection