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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ testfixtures_shared/

# build files generated
doc-tools/missing-doclet/bin/
mise.toml
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Implement FieldMappingIngestionMessageMapper for pull-based ingestion ([#20729](https://github.com/opensearch-project/OpenSearch/pull/20729))
- Added support of WarmerRefreshListener in NRTReplicationEngine to trigger warmer after replication on replica shards ([#20650](https://github.com/opensearch-project/OpenSearch/pull/20650))
- WLM group custom search settings - groundwork and timeout ([#20536](https://github.com/opensearch-project/OpenSearch/issues/20536))
- Add ref_path support for package-based hunspell dictionary loading and cache invalidation API ([#20741](https://github.com/opensearch-project/OpenSearch/pull/20741))
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description states "Add INDEX_REF_PATH_SETTING for package-based hunspell dictionaries" and "Add ref_path validation in MetadataCreateIndexService" as changes, but neither of these appears in the actual diff. There is no INDEX_REF_PATH_SETTING added and no changes to MetadataCreateIndexService. The PR description appears to be inaccurate with respect to these items.

Copilot uses AI. Check for mistakes.
- Add ref_path support for package-based hunspell dictionary loading and cache invalidation API ([#20792](https://github.com/opensearch-project/OpenSearch/pull/20792))

### 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,178 @@
/*
* 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 java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

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();
}

/**
* Sets up hunspell dictionary files in the node's config directory.
* Creates both traditional and package-based dictionaries.
*/
private void setupDictionaries(Path configDir) throws IOException {
// Traditional dictionary: config/hunspell/en_US/
Path traditionalDir = configDir.resolve("hunspell").resolve("en_US");
Files.createDirectories(traditionalDir);
Files.write(traditionalDir.resolve("en_US.aff"), "SET UTF-8\n".getBytes(StandardCharsets.UTF_8));
Files.write(traditionalDir.resolve("en_US.dic"), "1\nhello\n".getBytes(StandardCharsets.UTF_8));

// Package-based dictionary: config/packages/test-pkg/hunspell/en_US/
Path packageDir = configDir.resolve("packages").resolve("test-pkg").resolve("hunspell").resolve("en_US");
Files.createDirectories(packageDir);
Files.write(packageDir.resolve("en_US.aff"), "SET UTF-8\n".getBytes(StandardCharsets.UTF_8));
Files.write(packageDir.resolve("en_US.dic"), "1\nworld\n".getBytes(StandardCharsets.UTF_8));
}

// ==================== 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());
}
}
21 changes: 20 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,11 @@ protected void configure() {
bind(DynamicActionRegistry.class).toInstance(dynamicActionRegistry);

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

// Bind HunspellService for TransportHunspellCacheInvalidateAction injection
if (hunspellService != null) {
bind(HunspellService.class).toInstance(hunspellService);
}
Comment on lines +1128 to +1131
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When hunspellService is null (e.g., in test environments that pass null for this parameter), the HunspellService binding in Guice is skipped (conditional if (hunspellService != null)), but TransportHunspellCacheInvalidateAction and TransportHunspellCacheInfoAction are always registered in setupActions() and both require @Inject-ed HunspellService. If Guice tries to instantiate these transport actions (e.g., in integration tests that create a full Guice injector from ActionModule), it will fail with a binding error since HunspellService is not bound. The actions should only be registered when hunspellService is non-null, or HunspellService should always be non-nullable in ActionModule.

Copilot uses AI. Check for mistakes.
}

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