Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- WLM group custom search settings - groundwork and timeout ([#20536](https://github.com/opensearch-project/OpenSearch/issues/20536))
- Expose JVM runtime metrics via telemetry framework ([#20844](https://github.com/opensearch-project/OpenSearch/pull/20844))
- Add intra segment support for single-value metric aggregations ([#20503](https://github.com/opensearch-project/OpenSearch/pull/20503))
- Add ref_path support for package-based hunspell dictionary loading ([#20840](https://github.com/opensearch-project/OpenSearch/pull/20840))

### Changed
- Make telemetry `Tags` immutable ([#20788](https://github.com/opensearch-project/OpenSearch/pull/20788))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

package org.opensearch.action.admin.indices.cache.hunspell;

import org.opensearch.common.settings.Settings;
import org.opensearch.indices.analysis.HunspellService;
import org.opensearch.test.OpenSearchIntegTestCase;
import org.opensearch.test.OpenSearchIntegTestCase.ClusterScope;

import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;

/**
* Integration tests for hunspell cache info and invalidation APIs.
*
* <p>Tests the full REST→Transport→HunspellService flow on a real cluster:
* <ul>
* <li>GET /_hunspell/cache (HunspellCacheInfoAction)</li>
* <li>POST /_hunspell/cache/_invalidate (HunspellCacheInvalidateAction)</li>
* </ul>
*/
@ClusterScope(scope = OpenSearchIntegTestCase.Scope.TEST)
public class HunspellCacheIT extends OpenSearchIntegTestCase {

@Override
protected Settings nodeSettings(int nodeOrdinal) {
// Enable lazy loading so dictionaries are only loaded on-demand
return Settings.builder().put(super.nodeSettings(nodeOrdinal)).put(HunspellService.HUNSPELL_LAZY_LOAD.getKey(), true).build();
}

// ==================== Cache Info Tests ====================

public void testCacheInfoReturnsEmptyWhenNoDictionariesLoaded() {
HunspellCacheInfoResponse response = client().execute(HunspellCacheInfoAction.INSTANCE, new HunspellCacheInfoRequest()).actionGet();

assertThat(response.getTotalCachedCount(), equalTo(0));
assertThat(response.getPackageBasedCount(), equalTo(0));
assertThat(response.getTraditionalLocaleCount(), equalTo(0));
assertTrue(response.getPackageBasedKeys().isEmpty());
assertTrue(response.getTraditionalLocaleKeys().isEmpty());
}

// ==================== Cache Invalidation Tests ====================

public void testInvalidateAllOnEmptyCache() {
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
request.setInvalidateAll(true);

HunspellCacheInvalidateResponse response = client().execute(HunspellCacheInvalidateAction.INSTANCE, request).actionGet();

assertTrue(response.isAcknowledged());
assertThat(response.getInvalidatedCount(), equalTo(0));
}

public void testInvalidateByPackageIdOnEmptyCache() {
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
request.setPackageId("nonexistent-pkg");

HunspellCacheInvalidateResponse response = client().execute(HunspellCacheInvalidateAction.INSTANCE, request).actionGet();

assertTrue(response.isAcknowledged());
assertThat(response.getInvalidatedCount(), equalTo(0));
assertThat(response.getPackageId(), equalTo("nonexistent-pkg"));
}

public void testInvalidateByCacheKeyOnEmptyCache() {
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
request.setCacheKey("nonexistent-key");

HunspellCacheInvalidateResponse response = client().execute(HunspellCacheInvalidateAction.INSTANCE, request).actionGet();

assertTrue(response.isAcknowledged());
assertThat(response.getInvalidatedCount(), equalTo(0));
}

// ==================== Request Validation Tests ====================

public void testInvalidateRequestValidationFailsWithNoParameters() {
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
// No parameters set — should fail validation

assertNotNull(request.validate());
}

public void testInvalidateRequestValidationFailsWithConflictingParameters() {
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
request.setInvalidateAll(true);
request.setPackageId("some-pkg");

assertNotNull(request.validate());
}

public void testInvalidateRequestValidationFailsWithEmptyPackageId() {
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
request.setPackageId(" ");

assertNotNull(request.validate());
}

public void testInvalidateRequestValidationPassesWithPackageIdOnly() {
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
request.setPackageId("valid-pkg");

assertNull(request.validate());
}

public void testInvalidateRequestValidationPassesWithPackageIdAndLocale() {
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
request.setPackageId("valid-pkg");
request.setLocale("en_US");

assertNull(request.validate());
}

public void testInvalidateRequestValidationFailsWithLocaleWithoutPackageId() {
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
request.setLocale("en_US");

assertNotNull(request.validate());
}

// ==================== Response Schema Tests ====================

public void testInvalidateResponseAlwaysIncludesAllFields() {
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
request.setInvalidateAll(true);

HunspellCacheInvalidateResponse response = client().execute(HunspellCacheInvalidateAction.INSTANCE, request).actionGet();

// Response should always include these fields for consistent schema
assertTrue(response.isAcknowledged());
assertThat(response.getInvalidatedCount(), greaterThanOrEqualTo(0));
// Null fields should still be accessible (consistent schema)
assertNull(response.getPackageId());
assertNull(response.getLocale());
assertNull(response.getCacheKey());
}

public void testCacheInfoResponseSchema() {
HunspellCacheInfoResponse response = client().execute(HunspellCacheInfoAction.INSTANCE, new HunspellCacheInfoRequest()).actionGet();

// Verify response schema has all expected fields
assertThat(response.getTotalCachedCount(), greaterThanOrEqualTo(0));
assertThat(response.getPackageBasedCount(), greaterThanOrEqualTo(0));
assertThat(response.getTraditionalLocaleCount(), greaterThanOrEqualTo(0));
assertNotNull(response.getPackageBasedKeys());
assertNotNull(response.getTraditionalLocaleKeys());
}
}
25 changes: 24 additions & 1 deletion server/src/main/java/org/opensearch/action/ActionModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@
import org.opensearch.action.admin.indices.analyze.TransportAnalyzeAction;
import org.opensearch.action.admin.indices.cache.clear.ClearIndicesCacheAction;
import org.opensearch.action.admin.indices.cache.clear.TransportClearIndicesCacheAction;
import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInfoAction;
import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInvalidateAction;
import org.opensearch.action.admin.indices.cache.hunspell.TransportHunspellCacheInfoAction;
import org.opensearch.action.admin.indices.cache.hunspell.TransportHunspellCacheInvalidateAction;
import org.opensearch.action.admin.indices.close.CloseIndexAction;
import org.opensearch.action.admin.indices.close.TransportCloseIndexAction;
import org.opensearch.action.admin.indices.create.AutoCreateAction;
Expand Down Expand Up @@ -333,6 +337,7 @@
import org.opensearch.identity.IdentityService;
import org.opensearch.index.seqno.RetentionLeaseActions;
import org.opensearch.indices.SystemIndices;
import org.opensearch.indices.analysis.HunspellService;
import org.opensearch.persistent.CompletionPersistentTaskAction;
import org.opensearch.persistent.RemovePersistentTaskAction;
import org.opensearch.persistent.StartPersistentTaskAction;
Expand Down Expand Up @@ -419,6 +424,7 @@
import org.opensearch.rest.action.admin.indices.RestGetIngestionStateAction;
import org.opensearch.rest.action.admin.indices.RestGetMappingAction;
import org.opensearch.rest.action.admin.indices.RestGetSettingsAction;
import org.opensearch.rest.action.admin.indices.RestHunspellCacheInvalidateAction;
import org.opensearch.rest.action.admin.indices.RestIndexDeleteAliasesAction;
import org.opensearch.rest.action.admin.indices.RestIndexPutAliasAction;
import org.opensearch.rest.action.admin.indices.RestIndicesAliasesAction;
Expand Down Expand Up @@ -557,6 +563,7 @@ public class ActionModule extends AbstractModule {
private final ThreadPool threadPool;
private final ExtensionsManager extensionsManager;
private final ResponseLimitSettings responseLimitSettings;
private final HunspellService hunspellService;

public ActionModule(
Settings settings,
Expand All @@ -571,7 +578,8 @@ public ActionModule(
UsageService usageService,
SystemIndices systemIndices,
IdentityService identityService,
ExtensionsManager extensionsManager
ExtensionsManager extensionsManager,
HunspellService hunspellService
) {
this.settings = settings;
this.indexNameExpressionResolver = indexNameExpressionResolver;
Expand All @@ -581,6 +589,7 @@ public ActionModule(
this.actionPlugins = actionPlugins;
this.threadPool = threadPool;
this.extensionsManager = extensionsManager;
this.hunspellService = hunspellService;
actions = setupActions(actionPlugins);
actionFilters = setupActionFilters(actionPlugins);
dynamicActionRegistry = new DynamicActionRegistry();
Expand Down Expand Up @@ -725,6 +734,8 @@ public <Request extends ActionRequest, Response extends ActionResponse> void reg
actions.register(UpgradeStatusAction.INSTANCE, TransportUpgradeStatusAction.class);
actions.register(UpgradeSettingsAction.INSTANCE, TransportUpgradeSettingsAction.class);
actions.register(ClearIndicesCacheAction.INSTANCE, TransportClearIndicesCacheAction.class);
actions.register(HunspellCacheInfoAction.INSTANCE, TransportHunspellCacheInfoAction.class);
actions.register(HunspellCacheInvalidateAction.INSTANCE, TransportHunspellCacheInvalidateAction.class);
actions.register(GetAliasesAction.INSTANCE, TransportGetAliasesAction.class);
actions.register(GetSettingsAction.INSTANCE, TransportGetSettingsAction.class);

Expand Down Expand Up @@ -1075,6 +1086,9 @@ public void initRestHandlers(Supplier<DiscoveryNodes> nodesInCluster) {
registerHandler.accept(new RestPauseIngestionAction());
registerHandler.accept(new RestResumeIngestionAction());
registerHandler.accept(new RestGetIngestionStateAction());

// Hunspell cache management API
registerHandler.accept(new RestHunspellCacheInvalidateAction());
}

@Override
Expand Down Expand Up @@ -1110,6 +1124,15 @@ protected void configure() {
bind(DynamicActionRegistry.class).toInstance(dynamicActionRegistry);

bind(ResponseLimitSettings.class).toInstance(responseLimitSettings);

// Bind HunspellService for TransportHunspellCacheInfoAction and TransportHunspellCacheInvalidateAction injection.
// HunspellService is always available since it's created by AnalysisModule during node bootstrap.
// The null check is a defensive guard for edge cases in testing or unusual configurations.
if (hunspellService != null) {
bind(HunspellService.class).toInstance(hunspellService);
} else {
logger.warn("HunspellService is null; hunspell cache management APIs will not be functional");
}
}

public ActionFilters getActionFilters() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

package org.opensearch.action.admin.indices.cache.hunspell;

import org.opensearch.action.ActionType;

/**
* Action for retrieving Hunspell cache information.
*
* <p>This action requires "cluster:monitor/hunspell/cache" permission when security is enabled.
*
* @opensearch.internal
*/
public class HunspellCacheInfoAction extends ActionType<HunspellCacheInfoResponse> {

public static final HunspellCacheInfoAction INSTANCE = new HunspellCacheInfoAction();
public static final String NAME = "cluster:monitor/hunspell/cache";

private HunspellCacheInfoAction() {
super(NAME, HunspellCacheInfoResponse::new);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

package org.opensearch.action.admin.indices.cache.hunspell;

import org.opensearch.action.ActionRequest;
import org.opensearch.action.ActionRequestValidationException;
import org.opensearch.core.common.io.stream.StreamInput;
import org.opensearch.core.common.io.stream.StreamOutput;

import java.io.IOException;

/**
* Request for retrieving Hunspell cache information.
*
* @opensearch.internal
*/
public class HunspellCacheInfoRequest extends ActionRequest {

public HunspellCacheInfoRequest() {}

public HunspellCacheInfoRequest(StreamInput in) throws IOException {
super(in);
}

@Override
public void writeTo(StreamOutput out) throws IOException {
super.writeTo(out);
}

@Override
public ActionRequestValidationException validate() {
return null;
}
}
Loading
Loading