Skip to content

Commit 281beb2

Browse files
committed
Fail fast when k-NN plugin is missing
vectorSearch() currently lets queries proceed even when the k-NN plugin is not installed; the user sees a cryptic native OpenSearch error deep in execution. This change adds a lazy capability probe that runs on the first vectorSearch() invocation per JVM and throws a clear message when the plugin is absent. - KnnPluginCapability: probes Nodes Info once via NodeClient, caches the result. Successful results (positive or negative) are cached; probe failures (IO, timeout) are not, so subsequent calls retry. If no NodeClient is available (REST-client / standalone mode), the check is skipped and execution-time errors remain the signal. - The probe uses the canonical k-NN plugin class name rather than the artifact name so it survives repackaging variants. - Wired into VectorSearchTableFunctionImplementation.applyArguments() so the failure surfaces before any DSL is built. Resolver instantiates one shared capability per registration; the constructor remains backward-compatible (two-arg form delegates to the three-arg form). - Unit tests cover: node-client absent (skip), plugin present (pass), plugin absent (throw), positive/negative caching, and probe-failure non-caching. Plugin registration stays unconditional — failing at registration would break introspection (SHOW FUNCTIONS etc.) on clusters without k-NN. Signed-off-by: Eric Wei <mengwei.eric@gmail.com>
1 parent fe4b653 commit 281beb2

5 files changed

Lines changed: 279 additions & 10 deletions

File tree

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/VectorSearchTableFunctionImplementation.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import org.opensearch.sql.expression.function.FunctionName;
2929
import org.opensearch.sql.expression.function.TableFunctionImplementation;
3030
import org.opensearch.sql.opensearch.client.OpenSearchClient;
31+
import org.opensearch.sql.opensearch.storage.capability.KnnPluginCapability;
3132
import org.opensearch.sql.storage.Table;
3233

3334
public class VectorSearchTableFunctionImplementation extends FunctionExpression
@@ -47,17 +48,20 @@ public class VectorSearchTableFunctionImplementation extends FunctionExpression
4748
private final List<Expression> arguments;
4849
private final OpenSearchClient client;
4950
private final Settings settings;
51+
private final KnnPluginCapability knnCapability;
5052

5153
public VectorSearchTableFunctionImplementation(
5254
FunctionName functionName,
5355
List<Expression> arguments,
5456
OpenSearchClient client,
55-
Settings settings) {
57+
Settings settings,
58+
KnnPluginCapability knnCapability) {
5659
super(functionName, arguments);
5760
this.functionName = functionName;
5861
this.arguments = arguments;
5962
this.client = client;
6063
this.settings = settings;
64+
this.knnCapability = knnCapability;
6165
}
6266

6367
@Override
@@ -89,6 +93,7 @@ public String toString() {
8993

9094
@Override
9195
public Table applyArguments() {
96+
knnCapability.requireInstalled();
9297
validateNamedArgs();
9398
String tableName = getArgumentValue(TABLE);
9499
String fieldName = getArgumentValue(FIELD);

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/VectorSearchTableFunctionResolver.java

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import static org.opensearch.sql.data.type.ExprCoreType.STRING;
99

1010
import java.util.List;
11-
import lombok.RequiredArgsConstructor;
1211
import org.apache.commons.lang3.tuple.Pair;
1312
import org.opensearch.sql.common.setting.Settings;
1413
import org.opensearch.sql.expression.Expression;
@@ -17,8 +16,8 @@
1716
import org.opensearch.sql.expression.function.FunctionResolver;
1817
import org.opensearch.sql.expression.function.FunctionSignature;
1918
import org.opensearch.sql.opensearch.client.OpenSearchClient;
19+
import org.opensearch.sql.opensearch.storage.capability.KnnPluginCapability;
2020

21-
@RequiredArgsConstructor
2221
public class VectorSearchTableFunctionResolver implements FunctionResolver {
2322

2423
public static final String VECTOR_SEARCH = "vectorsearch";
@@ -30,6 +29,18 @@ public class VectorSearchTableFunctionResolver implements FunctionResolver {
3029

3130
private final OpenSearchClient client;
3231
private final Settings settings;
32+
private final KnnPluginCapability knnCapability;
33+
34+
public VectorSearchTableFunctionResolver(OpenSearchClient client, Settings settings) {
35+
this(client, settings, new KnnPluginCapability(client));
36+
}
37+
38+
VectorSearchTableFunctionResolver(
39+
OpenSearchClient client, Settings settings, KnnPluginCapability knnCapability) {
40+
this.client = client;
41+
this.settings = settings;
42+
this.knnCapability = knnCapability;
43+
}
3344

3445
@Override
3546
public Pair<FunctionSignature, FunctionBuilder> resolve(FunctionSignature unresolvedSignature) {
@@ -40,7 +51,7 @@ public Pair<FunctionSignature, FunctionBuilder> resolve(FunctionSignature unreso
4051
(functionProperties, arguments) -> {
4152
validateArguments(arguments);
4253
return new VectorSearchTableFunctionImplementation(
43-
functionName, arguments, client, settings);
54+
functionName, arguments, client, settings, knnCapability);
4455
};
4556
return Pair.of(functionSignature, functionBuilder);
4657
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.sql.opensearch.storage.capability;
7+
8+
import java.util.Objects;
9+
import java.util.Optional;
10+
import java.util.concurrent.atomic.AtomicReference;
11+
import org.opensearch.action.admin.cluster.node.info.NodesInfoRequest;
12+
import org.opensearch.action.admin.cluster.node.info.NodesInfoResponse;
13+
import org.opensearch.action.admin.cluster.node.info.PluginsAndModules;
14+
import org.opensearch.plugins.PluginInfo;
15+
import org.opensearch.sql.exception.ExpressionEvaluationException;
16+
import org.opensearch.sql.opensearch.client.OpenSearchClient;
17+
import org.opensearch.transport.client.node.NodeClient;
18+
19+
/**
20+
* Probes the cluster's Nodes Info API once and caches whether the k-NN plugin is installed, so
21+
* vectorSearch() fails fast with a clear error when the plugin is absent instead of surfacing a
22+
* native OpenSearch error deep in execution.
23+
*
24+
* <p>The probe requires a {@link NodeClient}. In REST-client mode (standalone SQL service) the node
25+
* client is absent and the check is skipped — execution-time errors remain the signal there.
26+
*
27+
* <p>The check runs lazily on the first vectorSearch() invocation so cluster boot is unaffected.
28+
*/
29+
public class KnnPluginCapability {
30+
31+
/**
32+
* Canonical k-NN plugin class. Using the class name (not artifact name) so the check is stable
33+
* across packaging variants.
34+
*/
35+
private static final String KNN_PLUGIN_CLASSNAME = "org.opensearch.knn.plugin.KNNPlugin";
36+
37+
private final OpenSearchClient client;
38+
private final AtomicReference<Boolean> cached = new AtomicReference<>();
39+
40+
public KnnPluginCapability(OpenSearchClient client) {
41+
this.client = client;
42+
}
43+
44+
/**
45+
* Throws {@link ExpressionEvaluationException} with a user-facing message if the k-NN plugin is
46+
* not installed on any node in the cluster. The result is cached after the first successful
47+
* probe; probe failures are not cached so the next call retries.
48+
*/
49+
public void requireInstalled() {
50+
Boolean hit = cached.get();
51+
if (hit == null) {
52+
Optional<Boolean> probed = probe();
53+
if (probed.isEmpty()) {
54+
// Probe unavailable (REST-client mode, no NodeClient). Don't block — execution-time
55+
// errors will surface if k-NN is genuinely missing.
56+
return;
57+
}
58+
hit = probed.get();
59+
cached.set(hit);
60+
}
61+
if (!hit) {
62+
throw new ExpressionEvaluationException(
63+
"vectorSearch() requires the k-NN plugin, which is not installed on this cluster."
64+
+ " Install opensearch-knn or use a cluster that has it.");
65+
}
66+
}
67+
68+
private Optional<Boolean> probe() {
69+
Optional<NodeClient> maybeNode = client.getNodeClient();
70+
if (maybeNode.isEmpty()) {
71+
return Optional.empty();
72+
}
73+
NodeClient node = maybeNode.get();
74+
try {
75+
NodesInfoRequest request = new NodesInfoRequest().clear().addMetric("plugins");
76+
NodesInfoResponse response = node.admin().cluster().nodesInfo(request).actionGet();
77+
boolean installed =
78+
response.getNodes().stream()
79+
.map(info -> info.getInfo(PluginsAndModules.class))
80+
.filter(Objects::nonNull)
81+
.flatMap(p -> p.getPluginInfos().stream())
82+
.map(PluginInfo::getClassname)
83+
.anyMatch(KNN_PLUGIN_CLASSNAME::equals);
84+
return Optional.of(installed);
85+
} catch (Exception e) {
86+
// Probe failed (IO error, timeout). Don't cache — let the next call retry.
87+
return Optional.empty();
88+
}
89+
}
90+
}

opensearch/src/test/java/org/opensearch/sql/opensearch/storage/VectorSearchTableFunctionImplementationTest.java

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.opensearch.sql.expression.Expression;
2323
import org.opensearch.sql.expression.function.FunctionName;
2424
import org.opensearch.sql.opensearch.client.OpenSearchClient;
25+
import org.opensearch.sql.opensearch.storage.capability.KnnPluginCapability;
2526
import org.opensearch.sql.storage.Table;
2627

2728
@ExtendWith(MockitoExtension.class)
@@ -31,6 +32,11 @@ class VectorSearchTableFunctionImplementationTest {
3132

3233
@Mock private Settings settings;
3334

35+
// No-op capability — tests in this class don't exercise the k-NN plugin probe.
36+
// Dedicated tests for the probe live in KnnPluginCapabilityTest.
37+
private final KnnPluginCapability knnCapability =
38+
org.mockito.Mockito.mock(KnnPluginCapability.class);
39+
3440
@Test
3541
void testValueOfThrows() {
3642
VectorSearchTableFunctionImplementation impl = createImpl();
@@ -61,6 +67,28 @@ void testApplyArguments() {
6167
assertTrue(table instanceof VectorSearchIndex);
6268
}
6369

70+
@Test
71+
void testApplyArgumentsPropagatesKnnCapabilityFailure() {
72+
// If the capability check fires, applyArguments() must not proceed to build the table.
73+
KnnPluginCapability throwingCapability = org.mockito.Mockito.mock(KnnPluginCapability.class);
74+
org.mockito.Mockito.doThrow(new ExpressionEvaluationException("k-NN plugin not installed"))
75+
.when(throwingCapability)
76+
.requireInstalled();
77+
FunctionName functionName = FunctionName.of("vectorsearch");
78+
List<Expression> args =
79+
List.of(
80+
DSL.namedArgument("table", DSL.literal("my-index")),
81+
DSL.namedArgument("field", DSL.literal("embedding")),
82+
DSL.namedArgument("vector", DSL.literal("[1.0, 2.0]")),
83+
DSL.namedArgument("option", DSL.literal("k=5")));
84+
VectorSearchTableFunctionImplementation impl =
85+
new VectorSearchTableFunctionImplementation(
86+
functionName, args, client, settings, throwingCapability);
87+
ExpressionEvaluationException ex =
88+
assertThrows(ExpressionEvaluationException.class, impl::applyArguments);
89+
assertTrue(ex.getMessage().contains("k-NN plugin"));
90+
}
91+
6492
@Test
6593
void testApplyArgumentsWithBracketedVector() {
6694
VectorSearchTableFunctionImplementation impl =
@@ -183,7 +211,8 @@ void testMissingArgumentThrows() {
183211
DSL.namedArgument("field", DSL.literal("embedding")),
184212
DSL.namedArgument("vector", DSL.literal("[1.0, 2.0]")));
185213
VectorSearchTableFunctionImplementation impl =
186-
new VectorSearchTableFunctionImplementation(functionName, args, client, settings);
214+
new VectorSearchTableFunctionImplementation(
215+
functionName, args, client, settings, knnCapability);
187216
ExpressionEvaluationException ex =
188217
assertThrows(ExpressionEvaluationException.class, () -> impl.applyArguments());
189218
assertEquals("Missing required argument: option", ex.getMessage());
@@ -297,7 +326,8 @@ void testNonNamedArgThrows() {
297326
FunctionName functionName = FunctionName.of("vectorsearch");
298327
List<Expression> args = List.of(DSL.literal("my-index"));
299328
VectorSearchTableFunctionImplementation impl =
300-
new VectorSearchTableFunctionImplementation(functionName, args, client, settings);
329+
new VectorSearchTableFunctionImplementation(
330+
functionName, args, client, settings, knnCapability);
301331
ExpressionEvaluationException ex =
302332
assertThrows(ExpressionEvaluationException.class, () -> impl.applyArguments());
303333
assertTrue(ex.getMessage().contains("requires named arguments"));
@@ -313,7 +343,8 @@ void testNullArgNameThrows() {
313343
DSL.namedArgument("vector", DSL.literal("[1.0, 2.0]")),
314344
DSL.namedArgument("option", DSL.literal("k=5")));
315345
VectorSearchTableFunctionImplementation impl =
316-
new VectorSearchTableFunctionImplementation(functionName, args, client, settings);
346+
new VectorSearchTableFunctionImplementation(
347+
functionName, args, client, settings, knnCapability);
317348
ExpressionEvaluationException ex =
318349
assertThrows(ExpressionEvaluationException.class, () -> impl.applyArguments());
319350
assertTrue(ex.getMessage().contains("requires named arguments"));
@@ -383,7 +414,8 @@ void testCaseInsensitiveArgLookup() {
383414
DSL.namedArgument("VECTOR", DSL.literal("[1.0, 2.0]")),
384415
DSL.namedArgument("OPTION", DSL.literal("k=5")));
385416
VectorSearchTableFunctionImplementation impl =
386-
new VectorSearchTableFunctionImplementation(functionName, args, client, settings);
417+
new VectorSearchTableFunctionImplementation(
418+
functionName, args, client, settings, knnCapability);
387419
Table table = impl.applyArguments();
388420
assertTrue(table instanceof VectorSearchIndex);
389421
}
@@ -398,7 +430,8 @@ void testInvalidFilterTypeRejects() {
398430
DSL.namedArgument("vector", DSL.literal("[1.0, 2.0]")),
399431
DSL.namedArgument("option", DSL.literal("k=5,filter_type=invalid")));
400432
VectorSearchTableFunctionImplementation impl =
401-
new VectorSearchTableFunctionImplementation(functionName, args, client, settings);
433+
new VectorSearchTableFunctionImplementation(
434+
functionName, args, client, settings, knnCapability);
402435
ExpressionEvaluationException ex =
403436
assertThrows(ExpressionEvaluationException.class, impl::applyArguments);
404437
assertTrue(ex.getMessage().contains("filter_type must be one of"));
@@ -440,6 +473,7 @@ private VectorSearchTableFunctionImplementation createImplWithArgs(
440473
DSL.namedArgument("field", DSL.literal(field)),
441474
DSL.namedArgument("vector", DSL.literal(vector)),
442475
DSL.namedArgument("option", DSL.literal(option)));
443-
return new VectorSearchTableFunctionImplementation(functionName, args, client, settings);
476+
return new VectorSearchTableFunctionImplementation(
477+
functionName, args, client, settings, knnCapability);
444478
}
445479
}

0 commit comments

Comments
 (0)