diff --git a/CHANGELOG.md b/CHANGELOG.md index c844a12bd5929..12cf01e389fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/server/src/internalClusterTest/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheIT.java b/server/src/internalClusterTest/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheIT.java new file mode 100644 index 0000000000000..8cd95996abbc5 --- /dev/null +++ b/server/src/internalClusterTest/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheIT.java @@ -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. + * + *

Tests the full REST→Transport→HunspellService flow on a real cluster: + *

+ */ +@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()); + } +} diff --git a/server/src/main/java/org/opensearch/action/ActionModule.java b/server/src/main/java/org/opensearch/action/ActionModule.java index 1ddb988dc6508..cf322748472e3 100644 --- a/server/src/main/java/org/opensearch/action/ActionModule.java +++ b/server/src/main/java/org/opensearch/action/ActionModule.java @@ -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; @@ -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; @@ -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; @@ -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, @@ -571,7 +578,8 @@ public ActionModule( UsageService usageService, SystemIndices systemIndices, IdentityService identityService, - ExtensionsManager extensionsManager + ExtensionsManager extensionsManager, + HunspellService hunspellService ) { this.settings = settings; this.indexNameExpressionResolver = indexNameExpressionResolver; @@ -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(); @@ -725,6 +734,8 @@ public 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); @@ -1075,6 +1086,9 @@ public void initRestHandlers(Supplier nodesInCluster) { registerHandler.accept(new RestPauseIngestionAction()); registerHandler.accept(new RestResumeIngestionAction()); registerHandler.accept(new RestGetIngestionStateAction()); + + // Hunspell cache management API + registerHandler.accept(new RestHunspellCacheInvalidateAction()); } @Override @@ -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() { diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoAction.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoAction.java new file mode 100644 index 0000000000000..90e9282dc7efa --- /dev/null +++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoAction.java @@ -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. + * + *

This action requires "cluster:monitor/hunspell/cache" permission when security is enabled. + * + * @opensearch.internal + */ +public class HunspellCacheInfoAction extends ActionType { + + public static final HunspellCacheInfoAction INSTANCE = new HunspellCacheInfoAction(); + public static final String NAME = "cluster:monitor/hunspell/cache"; + + private HunspellCacheInfoAction() { + super(NAME, HunspellCacheInfoResponse::new); + } +} diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoRequest.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoRequest.java new file mode 100644 index 0000000000000..f1ec5161468e2 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoRequest.java @@ -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; + } +} diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoResponse.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoResponse.java new file mode 100644 index 0000000000000..c29d258ec4482 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoResponse.java @@ -0,0 +1,97 @@ +/* + * 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.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +/** + * Response for Hunspell cache information. + * + * @opensearch.internal + */ +public class HunspellCacheInfoResponse extends ActionResponse implements ToXContentObject { + + private final int totalCachedCount; + private final int packageBasedCount; + private final int traditionalLocaleCount; + private final Set packageBasedKeys; + private final Set traditionalLocaleKeys; + + public HunspellCacheInfoResponse( + int totalCachedCount, + int packageBasedCount, + int traditionalLocaleCount, + Set packageBasedKeys, + Set traditionalLocaleKeys + ) { + this.totalCachedCount = totalCachedCount; + this.packageBasedCount = packageBasedCount; + this.traditionalLocaleCount = traditionalLocaleCount; + this.packageBasedKeys = packageBasedKeys; + this.traditionalLocaleKeys = traditionalLocaleKeys; + } + + public HunspellCacheInfoResponse(StreamInput in) throws IOException { + super(in); + this.totalCachedCount = in.readVInt(); + this.packageBasedCount = in.readVInt(); + this.traditionalLocaleCount = in.readVInt(); + this.packageBasedKeys = new HashSet<>(in.readStringList()); + this.traditionalLocaleKeys = new HashSet<>(in.readStringList()); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeVInt(totalCachedCount); + out.writeVInt(packageBasedCount); + out.writeVInt(traditionalLocaleCount); + out.writeStringCollection(packageBasedKeys); + out.writeStringCollection(traditionalLocaleKeys); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field("total_cached_count", totalCachedCount); + builder.field("package_based_count", packageBasedCount); + builder.field("traditional_locale_count", traditionalLocaleCount); + builder.array("package_based_keys", packageBasedKeys.toArray(new String[0])); + builder.array("traditional_locale_keys", traditionalLocaleKeys.toArray(new String[0])); + builder.endObject(); + return builder; + } + + public int getTotalCachedCount() { + return totalCachedCount; + } + + public int getPackageBasedCount() { + return packageBasedCount; + } + + public int getTraditionalLocaleCount() { + return traditionalLocaleCount; + } + + public Set getPackageBasedKeys() { + return packageBasedKeys; + } + + public Set getTraditionalLocaleKeys() { + return traditionalLocaleKeys; + } +} diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateAction.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateAction.java new file mode 100644 index 0000000000000..8f48fcd343fb1 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateAction.java @@ -0,0 +1,30 @@ +/* + * 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 type for invalidating Hunspell dictionary cache. + * + * This action requires cluster admin permissions when the security plugin is enabled. + * The action name "cluster:admin/hunspell/cache/invalidate" should be added to the cluster_permissions + * section of a role definition for authorization. + * + * @opensearch.internal + */ +public class HunspellCacheInvalidateAction extends ActionType { + + public static final HunspellCacheInvalidateAction INSTANCE = new HunspellCacheInvalidateAction(); + public static final String NAME = "cluster:admin/hunspell/cache/invalidate"; + + private HunspellCacheInvalidateAction() { + super(NAME, HunspellCacheInvalidateResponse::new); + } +} diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateRequest.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateRequest.java new file mode 100644 index 0000000000000..fa3c683543413 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateRequest.java @@ -0,0 +1,143 @@ +/* + * 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.Strings; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +import java.io.IOException; + +/** + * Request for Hunspell cache invalidation. + * + *

Supports three modes: + *

+ * + * @opensearch.internal + */ +public class HunspellCacheInvalidateRequest extends ActionRequest { + + private String packageId; + private String locale; + private String cacheKey; + private boolean invalidateAll; + + public HunspellCacheInvalidateRequest() {} + + public HunspellCacheInvalidateRequest(StreamInput in) throws IOException { + super(in); + this.packageId = in.readOptionalString(); + this.locale = in.readOptionalString(); + this.cacheKey = in.readOptionalString(); + this.invalidateAll = in.readBoolean(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeOptionalString(packageId); + out.writeOptionalString(locale); + out.writeOptionalString(cacheKey); + out.writeBoolean(invalidateAll); + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException e = null; + + // Reject empty/blank strings with clear error messages + if (packageId != null && !Strings.hasText(packageId)) { + e = new ActionRequestValidationException(); + e.addValidationError("'package_id' cannot be empty or blank"); + } + if (locale != null && !Strings.hasText(locale)) { + if (e == null) e = new ActionRequestValidationException(); + e.addValidationError("'locale' cannot be empty or blank"); + } + if (cacheKey != null && !Strings.hasText(cacheKey)) { + if (e == null) e = new ActionRequestValidationException(); + e.addValidationError("'cache_key' cannot be empty or blank"); + } + + // If any blank validation errors, return early + if (e != null) { + return e; + } + + // Count how many modes are specified + int modeCount = 0; + if (invalidateAll) modeCount++; + if (packageId != null) modeCount++; + if (cacheKey != null) modeCount++; + + if (modeCount == 0) { + e = new ActionRequestValidationException(); + e.addValidationError("Either 'package_id', 'cache_key', or 'invalidate_all' must be specified"); + } else if (modeCount > 1) { + e = new ActionRequestValidationException(); + if (invalidateAll && (packageId != null || cacheKey != null)) { + e.addValidationError("'invalidate_all' cannot be combined with 'package_id' or 'cache_key'"); + } else { + e.addValidationError("Only one of 'package_id' or 'cache_key' can be specified, not both"); + } + } + + // locale is only valid with package_id + if (locale != null && packageId == null) { + if (e == null) e = new ActionRequestValidationException(); + e.addValidationError("'locale' can only be specified together with 'package_id'"); + } + + return e; + } + + public String getPackageId() { + return packageId; + } + + public HunspellCacheInvalidateRequest setPackageId(String packageId) { + this.packageId = packageId; + return this; + } + + public String getLocale() { + return locale; + } + + public HunspellCacheInvalidateRequest setLocale(String locale) { + this.locale = locale; + return this; + } + + public String getCacheKey() { + return cacheKey; + } + + public HunspellCacheInvalidateRequest setCacheKey(String cacheKey) { + this.cacheKey = cacheKey; + return this; + } + + public boolean isInvalidateAll() { + return invalidateAll; + } + + public HunspellCacheInvalidateRequest setInvalidateAll(boolean invalidateAll) { + this.invalidateAll = invalidateAll; + return this; + } +} diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateResponse.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateResponse.java new file mode 100644 index 0000000000000..30aea72e4bca2 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateResponse.java @@ -0,0 +1,89 @@ +/* + * 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.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; + +import java.io.IOException; + +/** + * Response for Hunspell cache invalidation action. + * + * @opensearch.internal + */ +public class HunspellCacheInvalidateResponse extends ActionResponse implements ToXContentObject { + + private final boolean acknowledged; + private final int invalidatedCount; + private final String packageId; + private final String locale; + private final String cacheKey; + + public HunspellCacheInvalidateResponse(boolean acknowledged, int invalidatedCount, String packageId, String locale, String cacheKey) { + this.acknowledged = acknowledged; + this.invalidatedCount = invalidatedCount; + this.packageId = packageId; + this.locale = locale; + this.cacheKey = cacheKey; + } + + public HunspellCacheInvalidateResponse(StreamInput in) throws IOException { + super(in); + this.acknowledged = in.readBoolean(); + this.invalidatedCount = in.readInt(); + this.packageId = in.readOptionalString(); + this.locale = in.readOptionalString(); + this.cacheKey = in.readOptionalString(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeBoolean(acknowledged); + out.writeInt(invalidatedCount); + out.writeOptionalString(packageId); + out.writeOptionalString(locale); + out.writeOptionalString(cacheKey); + } + + public boolean isAcknowledged() { + return acknowledged; + } + + public int getInvalidatedCount() { + return invalidatedCount; + } + + public String getPackageId() { + return packageId; + } + + public String getLocale() { + return locale; + } + + public String getCacheKey() { + return cacheKey; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field("acknowledged", acknowledged); + builder.field("invalidated_count", invalidatedCount); + builder.field("package_id", packageId); + builder.field("locale", locale); + builder.field("cache_key", cacheKey); + builder.endObject(); + return builder; + } +} diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInfoAction.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInfoAction.java new file mode 100644 index 0000000000000..77f49b39c1c6c --- /dev/null +++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInfoAction.java @@ -0,0 +1,72 @@ +/* + * 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.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.action.ActionListener; +import org.opensearch.indices.analysis.HunspellService; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +import java.util.HashSet; +import java.util.Set; + +/** + * Transport action for retrieving Hunspell cache information. + * + *

Requires "cluster:monitor/hunspell/cache" permission when security is enabled. + * + * @opensearch.internal + */ +public class TransportHunspellCacheInfoAction extends HandledTransportAction { + + private final HunspellService hunspellService; + + @Inject + public TransportHunspellCacheInfoAction( + TransportService transportService, + ActionFilters actionFilters, + HunspellService hunspellService + ) { + super(HunspellCacheInfoAction.NAME, transportService, actionFilters, HunspellCacheInfoRequest::new); + this.hunspellService = hunspellService; + } + + @Override + protected void doExecute(Task task, HunspellCacheInfoRequest request, ActionListener listener) { + try { + Set cachedKeys = hunspellService.getCachedDictionaryKeys(); + + Set packageKeys = new HashSet<>(); + Set localeKeys = new HashSet<>(); + + for (String key : cachedKeys) { + if (HunspellService.isPackageCacheKey(key)) { + packageKeys.add(key); + } else { + localeKeys.add(key); + } + } + + HunspellCacheInfoResponse response = new HunspellCacheInfoResponse( + cachedKeys.size(), + packageKeys.size(), + localeKeys.size(), + packageKeys, + localeKeys + ); + + listener.onResponse(response); + } catch (Exception e) { + listener.onFailure(e); + } + } +} diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInvalidateAction.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInvalidateAction.java new file mode 100644 index 0000000000000..4521693581d48 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInvalidateAction.java @@ -0,0 +1,78 @@ +/* + * 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.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.action.ActionListener; +import org.opensearch.indices.analysis.HunspellService; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +/** + * Transport action for Hunspell cache invalidation. + * + * This action is authorized via the "cluster:admin/hunspell/cache/invalidate" permission. + * When the OpenSearch Security plugin is enabled, only users with cluster admin + * permissions can execute this action. + * + * @opensearch.internal + */ +public class TransportHunspellCacheInvalidateAction extends HandledTransportAction< + HunspellCacheInvalidateRequest, + HunspellCacheInvalidateResponse> { + + private final HunspellService hunspellService; + + @Inject + public TransportHunspellCacheInvalidateAction( + TransportService transportService, + ActionFilters actionFilters, + HunspellService hunspellService + ) { + super(HunspellCacheInvalidateAction.NAME, transportService, actionFilters, HunspellCacheInvalidateRequest::new); + this.hunspellService = hunspellService; + } + + @Override + protected void doExecute(Task task, HunspellCacheInvalidateRequest request, ActionListener listener) { + try { + String packageId = request.getPackageId(); + String locale = request.getLocale(); + String cacheKey = request.getCacheKey(); + boolean invalidateAll = request.isInvalidateAll(); + + int invalidatedCount = 0; + String responseCacheKey = null; + + if (invalidateAll) { + // Invalidate all cached dictionaries + invalidatedCount = hunspellService.invalidateAllDictionaries(); + } else if (packageId != null && locale != null) { + // Invalidate specific package + locale combination + responseCacheKey = HunspellService.buildPackageCacheKey(packageId, locale); + boolean invalidated = hunspellService.invalidateDictionary(responseCacheKey); + invalidatedCount = invalidated ? 1 : 0; + } else if (packageId != null) { + // Invalidate all locales for a package + invalidatedCount = hunspellService.invalidateDictionariesByPackage(packageId); + } else if (cacheKey != null) { + // Invalidate a specific cache key directly + responseCacheKey = cacheKey; + boolean invalidated = hunspellService.invalidateDictionary(cacheKey); + invalidatedCount = invalidated ? 1 : 0; + } + + listener.onResponse(new HunspellCacheInvalidateResponse(true, invalidatedCount, packageId, locale, responseCacheKey)); + } catch (Exception e) { + listener.onFailure(e); + } + } +} diff --git a/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/package-info.java b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/package-info.java new file mode 100644 index 0000000000000..b024fe631ddc5 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/package-info.java @@ -0,0 +1,10 @@ +/* + * 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. + */ + +/** Hunspell cache management transport handlers for invalidation and info operations. */ +package org.opensearch.action.admin.indices.cache.hunspell; diff --git a/server/src/main/java/org/opensearch/index/analysis/HunspellTokenFilterFactory.java b/server/src/main/java/org/opensearch/index/analysis/HunspellTokenFilterFactory.java index dcfb77a90481d..612ba558bb37d 100644 --- a/server/src/main/java/org/opensearch/index/analysis/HunspellTokenFilterFactory.java +++ b/server/src/main/java/org/opensearch/index/analysis/HunspellTokenFilterFactory.java @@ -39,10 +39,35 @@ import org.opensearch.indices.analysis.HunspellService; import java.util.Locale; +import java.util.regex.Pattern; /** * The token filter factory for the hunspell analyzer * + * Supports hot-reload when used with {@code updateable: true} setting. + * The dictionary is loaded from either: + *

+ * + *

Usage Examples:

+ *
+ * // Traditional locale-based (loads from config/hunspell/en_US/)
+ * {
+ *   "type": "hunspell",
+ *   "locale": "en_US"
+ * }
+ *
+ * // Package-based (loads from config/packages/pkg-1234/hunspell/en_US/)
+ * {
+ *   "type": "hunspell",
+ *   "ref_path": "pkg-1234",
+ *   "locale": "en_US"
+ * }
+ * 
+ * + * * @opensearch.internal */ public class HunspellTokenFilterFactory extends AbstractTokenFilterFactory { @@ -50,18 +75,46 @@ public class HunspellTokenFilterFactory extends AbstractTokenFilterFactory { private final Dictionary dictionary; private final boolean dedup; private final boolean longestOnly; + private final AnalysisMode analysisMode; public HunspellTokenFilterFactory(IndexSettings indexSettings, String name, Settings settings, HunspellService hunspellService) { super(indexSettings, name, settings); + // Check for updateable flag - enables hot-reload support (same pattern as SynonymTokenFilterFactory) + boolean updateable = settings.getAsBoolean("updateable", false); + this.analysisMode = updateable ? AnalysisMode.SEARCH_TIME : AnalysisMode.ALL; + // Get both ref_path and locale parameters + String refPath = settings.get("ref_path"); // Package ID only (optional) String locale = settings.get("locale", settings.get("language", settings.get("lang", null))); - if (locale == null) { - throw new IllegalArgumentException("missing [locale | language | lang] configuration for hunspell token filter"); - } - dictionary = hunspellService.getDictionary(locale); - if (dictionary == null) { - throw new IllegalArgumentException(String.format(Locale.ROOT, "Unknown hunspell dictionary for locale [%s]", locale)); + if (refPath != null) { + // Package-based loading: ref_path (package ID) + locale (required) + if (locale == null) { + throw new IllegalArgumentException("When using ref_path, the 'locale' parameter is required for hunspell token filter"); + } + + // Validate ref_path and locale are safe package/locale identifiers + validatePackageIdentifier(refPath, "ref_path"); + validatePackageIdentifier(locale, "locale"); + + // Load from package directory: config/packages/{ref_path}/hunspell/{locale}/ + dictionary = hunspellService.getDictionaryFromPackage(refPath, locale); + if (dictionary == null) { + throw new IllegalArgumentException( + String.format(Locale.ROOT, "Could not find hunspell dictionary for locale [%s] in package [%s]", locale, refPath) + ); + } + } else if (locale != null) { + // Traditional locale-based loading (backward compatible) + // Loads from config/hunspell/{locale}/ + // Validate locale to prevent path traversal and cache key ambiguity + validatePackageIdentifier(locale, "locale"); + dictionary = hunspellService.getDictionary(locale); + if (dictionary == null) { + throw new IllegalArgumentException(String.format(Locale.ROOT, "Unknown hunspell dictionary for locale [%s]", locale)); + } + } else { + throw new IllegalArgumentException("missing [locale | language | lang] configuration for hunspell token filter"); } dedup = settings.getAsBoolean("dedup", true); @@ -73,6 +126,16 @@ public TokenStream create(TokenStream tokenStream) { return new HunspellStemFilter(tokenStream, dictionary, dedup, longestOnly); } + /** + * Returns the analysis mode for this filter. + * When {@code updateable: true} is set, returns {@code SEARCH_TIME} which enables hot-reload + * via the _refresh_search_analyzers API. + */ + @Override + public AnalysisMode getAnalysisMode() { + return this.analysisMode; + } + public boolean dedup() { return dedup; } @@ -81,4 +144,45 @@ public boolean longestOnly() { return longestOnly; } + /** + * Allowlist pattern for safe package identifiers and locales. + * Permits alphanumeric characters, hyphens, underscores, and dots (but not leading/trailing dots). + * Examples: "pkg-1234", "en_US", "my-package-v2", "en_US_custom" + */ + private static final Pattern SAFE_IDENTIFIER_PATTERN = Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$"); + + /** + * Validates that a package identifier or locale contains only safe characters. + * Uses an allowlist approach: only alphanumeric, hyphen, underscore, and dot (not leading/trailing) are permitted. + * This prevents path traversal, cache key injection, and other security issues. + * + * @param value The value to validate (package ID or locale) + * @param paramName The parameter name for error messages + * @throws IllegalArgumentException if validation fails + */ + static void validatePackageIdentifier(String value, String paramName) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(String.format(Locale.ROOT, "Invalid %s: value cannot be null or empty.", paramName)); + } + + if (!SAFE_IDENTIFIER_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException( + String.format( + Locale.ROOT, + "Invalid %s: [%s]. Only alphanumeric characters, hyphens, underscores, " + + "and dots (not leading/trailing) are allowed.", + paramName, + value + ) + ); + } + + // Additional check: reject ".." sequences even within otherwise valid characters (e.g., "foo..bar") + if (value.contains("..")) { + throw new IllegalArgumentException( + String.format(Locale.ROOT, "Invalid %s: [%s]. Consecutive dots ('..') are not allowed.", paramName, value) + ); + } + } + } diff --git a/server/src/main/java/org/opensearch/indices/analysis/AnalysisModule.java b/server/src/main/java/org/opensearch/indices/analysis/AnalysisModule.java index dbb3035a18f74..02425fb3dffdc 100644 --- a/server/src/main/java/org/opensearch/indices/analysis/AnalysisModule.java +++ b/server/src/main/java/org/opensearch/indices/analysis/AnalysisModule.java @@ -119,7 +119,7 @@ public AnalysisModule(Environment environment, List plugins) thr ); } - HunspellService getHunspellService() { + public HunspellService getHunspellService() { return hunspellService; } diff --git a/server/src/main/java/org/opensearch/indices/analysis/HunspellService.java b/server/src/main/java/org/opensearch/indices/analysis/HunspellService.java index 027cd502da1fb..8884121f850d3 100644 --- a/server/src/main/java/org/opensearch/indices/analysis/HunspellService.java +++ b/server/src/main/java/org/opensearch/indices/analysis/HunspellService.java @@ -42,6 +42,7 @@ import org.opensearch.common.settings.Setting.Property; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.io.IOUtils; +import org.opensearch.core.common.Strings; import org.opensearch.core.util.FileSystemUtils; import org.opensearch.env.Environment; @@ -55,36 +56,37 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; /** - * Serves as a node level registry for hunspell dictionaries. This services expects all dictionaries to be located under - * the {@code /hunspell} directory, where each locale has its dedicated sub-directory which holds the dictionary - * files. For example, the dictionary files for {@code en_US} locale must be placed under {@code /hunspell/en_US} - * directory. - *

- * The following settings can be set for each dictionary: + * Serves as a node level registry for hunspell dictionaries. This service supports loading dictionaries from: + *

    + *
  • Traditional location: {@code /hunspell//} (e.g., config/hunspell/en_US/)
  • + *
  • Package-based location: {@code /packages//hunspell//} (e.g., config/packages/pkg-1234/hunspell/en_US/)
  • + *
+ * + *

Cache Key Strategy:

+ *
    + *
  • Traditional dictionaries: Cache key = locale (e.g., "en_US")
  • + *
  • Package-based dictionaries: Cache key = "{packageId}:{locale}" (e.g., "pkg-1234:en_US")
  • + *
+ * + *

The following settings can be set for each dictionary: *

    *
  • {@code ignore_case} - If true, dictionary matching will be case insensitive (defaults to {@code false})
  • *
  • {@code strict_affix_parsing} - Determines whether errors while reading a affix rules file will cause exception or simple be ignored * (defaults to {@code true})
  • *
- *

- * These settings can either be configured as node level configuration, such as: - *

+ * + *

These settings can either be configured as node level configuration, such as: *


  *     indices.analysis.hunspell.dictionary.en_US.ignore_case: true
  *     indices.analysis.hunspell.dictionary.en_US.strict_affix_parsing: false
  * 
- *

- * or, as dedicated configuration per dictionary, placed in a {@code settings.yml} file under the dictionary directory. For - * example, the following can be the content of the {@code /hunspell/en_US/settings.yml} file: - *

- *


- *     ignore_case: true
- *     strict_affix_parsing: false
- * 
+ * + *

or, as dedicated configuration per dictionary, placed in a {@code settings.yml} file under the dictionary directory. * * @see org.opensearch.index.analysis.HunspellTokenFilterFactory * @@ -94,6 +96,9 @@ public class HunspellService { private static final Logger logger = LogManager.getLogger(HunspellService.class); + /** Separator used in cache keys for package-based dictionaries: "{packageId}:{locale}" */ + private static final String CACHE_KEY_SEPARATOR = ":"; + public static final Setting HUNSPELL_LAZY_LOAD = Setting.boolSetting( "indices.analysis.hunspell.dictionary.lazy", Boolean.FALSE, @@ -112,11 +117,13 @@ public class HunspellService { private final Map knownDictionaries; private final boolean defaultIgnoreCase; private final Path hunspellDir; + private final Environment env; private final Function loadingFunction; public HunspellService(final Settings settings, final Environment env, final Map knownDictionaries) throws IOException { this.knownDictionaries = Collections.unmodifiableMap(knownDictionaries); + this.env = env; this.hunspellDir = resolveHunspellDirectory(env); this.defaultIgnoreCase = HUNSPELL_IGNORE_CASE.get(settings); this.loadingFunction = (locale) -> { @@ -135,8 +142,10 @@ public HunspellService(final Settings settings, final Environment env, final Map /** * Returns the hunspell dictionary for the given locale. + * Loads from traditional location: config/hunspell/{locale}/ * - * @param locale The name of the locale + * @param locale The name of the locale (e.g., "en_US") + * @return The loaded Dictionary */ public Dictionary getDictionary(String locale) { Dictionary dictionary = knownDictionaries.get(locale); @@ -146,6 +155,146 @@ public Dictionary getDictionary(String locale) { return dictionary; } + /** + * Returns the hunspell dictionary from a package directory. + * Loads from package location: config/packages/{packageId}/hunspell/{locale}/ + * + *

Cache key format: "{packageId}:{locale}" (e.g., "pkg-1234:en_US") + * + * @param packageId The package ID (e.g., "pkg-1234") + * @param locale The locale (e.g., "en_US") + * @return The loaded Dictionary + * @throws IllegalArgumentException if packageId or locale is null + * @throws IllegalStateException if hunspell directory not found or dictionary cannot be loaded + */ + public Dictionary getDictionaryFromPackage(String packageId, String locale) { + if (Strings.isNullOrEmpty(packageId)) { + throw new IllegalArgumentException("packageId cannot be null or empty"); + } + if (Strings.isNullOrEmpty(locale)) { + throw new IllegalArgumentException("locale cannot be null or empty"); + } + + String cacheKey = buildPackageCacheKey(packageId, locale); + + return dictionaries.computeIfAbsent(cacheKey, (key) -> { + try { + return loadDictionaryFromPackage(packageId, locale); + } catch (Exception e) { + + throw new IllegalStateException( + String.format(Locale.ROOT, "Failed to load hunspell dictionary for package [%s] locale [%s]", packageId, locale), + e + ); + } + }); + } + + /** + * Loads a hunspell dictionary from a package directory. + * Expects hunspell files at: config/packages/{packageId}/hunspell/{locale}/ + * + * @param packageId The package identifier + * @param locale The locale (e.g., "en_US") + * @return The loaded Dictionary + * @throws Exception if loading fails + */ + private Dictionary loadDictionaryFromPackage(String packageId, String locale) throws Exception { + // Validate raw inputs before path resolution (defense-in-depth, caller should also validate) + if (packageId.contains("/") || packageId.contains("\\") || packageId.contains("..")) { + throw new IllegalArgumentException( + String.format(Locale.ROOT, "Invalid package ID: [%s]. Must not contain path separators or '..' sequences.", packageId) + ); + } + if (locale.contains("/") || locale.contains("\\") || locale.contains("..")) { + throw new IllegalArgumentException( + String.format(Locale.ROOT, "Invalid locale: [%s]. Must not contain path separators or '..' sequences.", locale) + ); + } + + // Resolve packages base directory: config/packages/ + Path packagesBaseDir = env.configDir().resolve("packages"); + + // Resolve package directory: config/packages/{packageId}/ + Path packageDir = packagesBaseDir.resolve(packageId); + + // Security check: ensure path stays under config/packages/ (prevent path traversal attacks) + // Both paths must be converted to absolute and normalized before comparison + // Defense-in-depth: raw input validation above should prevent this, but we verify + // the resolved path as a secondary safeguard against any future code path changes + Path packagesBaseDirAbsolute = packagesBaseDir.toAbsolutePath().normalize(); + Path packageDirAbsolute = packageDir.toAbsolutePath().normalize(); + if (!packageDirAbsolute.startsWith(packagesBaseDirAbsolute)) { + throw new IllegalArgumentException( + String.format(Locale.ROOT, "Package path must be under config/packages directory. Package: [%s]", packageId) + ); + } + + // Additional check: ensure the resolved package directory is exactly one level under packages/ + // This prevents packageId=".." or "foo/../bar" from escaping + if (!packageDirAbsolute.getParent().equals(packagesBaseDirAbsolute)) { + throw new IllegalArgumentException( + String.format(Locale.ROOT, "Invalid package ID: [%s]. Package ID cannot contain path traversal sequences.", packageId) + ); + } + + // Check if package directory exists + if (!Files.isDirectory(packageDir)) { + throw new OpenSearchException( + String.format(Locale.ROOT, "Package directory not found: [%s]. Expected at: %s", packageId, packageDir) + ); + } + + // Auto-detect hunspell directory within package + Path packageHunspellDir = packageDir.resolve("hunspell"); + if (!Files.isDirectory(packageHunspellDir)) { + throw new OpenSearchException( + String.format( + Locale.ROOT, + "Hunspell directory not found in package [%s]. " + "Expected 'hunspell' subdirectory at: %s", + packageId, + packageHunspellDir + ) + ); + } + + // Resolve locale directory within hunspell + Path dicDir = packageHunspellDir.resolve(locale); + + // Security check: ensure locale path doesn't escape hunspell directory (prevent path traversal) + Path hunspellDirAbsolute = packageHunspellDir.toAbsolutePath().normalize(); + Path dicDirAbsolute = dicDir.toAbsolutePath().normalize(); + if (!dicDirAbsolute.startsWith(hunspellDirAbsolute)) { + throw new IllegalArgumentException( + String.format(Locale.ROOT, "Locale path must be under hunspell directory. Locale: [%s]", locale) + ); + } + + if (logger.isDebugEnabled()) { + logger.debug("Loading hunspell dictionary from package [{}] locale [{}] at [{}]...", packageId, locale, dicDirAbsolute); + } + + if (!FileSystemUtils.isAccessibleDirectory(dicDir, logger)) { + throw new OpenSearchException( + String.format( + Locale.ROOT, + "Locale [%s] not found in package [%s]. " + "Expected directory at: %s", + locale, + packageId, + dicDirAbsolute + ) + ); + } + + // Load settings from the locale directory if present + Settings dictSettings = loadDictionarySettings(dicDir, Settings.EMPTY); + boolean ignoreCase = dictSettings.getAsBoolean("ignore_case", defaultIgnoreCase); + + // Delegate to shared dictionary loading logic + String description = String.format(Locale.ROOT, "package [%s] locale [%s]", packageId, locale); + return loadDictionaryFromDirectory(dicDir, ignoreCase, description); + } + private Path resolveHunspellDirectory(Environment env) { return env.configDir().resolve("hunspell"); } @@ -202,31 +351,45 @@ private Dictionary loadDictionary(String locale, Settings nodeSettings, Environm boolean ignoreCase = nodeSettings.getAsBoolean("ignore_case", defaultIgnoreCase); + // Delegate to shared dictionary loading logic + return loadDictionaryFromDirectory(dicDir, ignoreCase, locale); + } + + /** + * Shared logic to load a hunspell Dictionary from a directory containing .aff and .dic files. + * Used by both traditional locale-based loading and package-based loading. + * + * @param dicDir The directory containing the .aff and .dic files + * @param ignoreCase Whether dictionary matching should be case insensitive + * @param description A human-readable description for error messages (e.g., "en_US" or "package [pkg-1234] locale [en_US]") + * @return The loaded Dictionary + * @throws Exception if loading fails + */ + private Dictionary loadDictionaryFromDirectory(Path dicDir, boolean ignoreCase, String description) throws Exception { Path[] affixFiles = FileSystemUtils.files(dicDir, "*.aff"); if (affixFiles.length == 0) { - throw new OpenSearchException(String.format(Locale.ROOT, "Missing affix file for hunspell dictionary [%s]", locale)); + throw new OpenSearchException(String.format(Locale.ROOT, "Missing affix file for hunspell dictionary [%s]", description)); } if (affixFiles.length != 1) { - throw new OpenSearchException(String.format(Locale.ROOT, "Too many affix files exist for hunspell dictionary [%s]", locale)); + throw new OpenSearchException( + String.format(Locale.ROOT, "Too many affix files exist for hunspell dictionary [%s]", description) + ); } - InputStream affixStream = null; Path[] dicFiles = FileSystemUtils.files(dicDir, "*.dic"); List dicStreams = new ArrayList<>(dicFiles.length); + InputStream affixStream = null; try { - - for (int i = 0; i < dicFiles.length; i++) { - dicStreams.add(Files.newInputStream(dicFiles[i])); + for (Path dicFile : dicFiles) { + dicStreams.add(Files.newInputStream(dicFile)); } - affixStream = Files.newInputStream(affixFiles[0]); try (Directory tmp = new NIOFSDirectory(env.tmpDir())) { return new Dictionary(tmp, "hunspell", affixStream, dicStreams, ignoreCase); } - } catch (Exception e) { - logger.error(() -> new ParameterizedMessage("Could not load hunspell dictionary [{}]", locale), e); + logger.error(() -> new ParameterizedMessage("Could not load hunspell dictionary [{}]", description), e); throw e; } finally { IOUtils.close(affixStream); @@ -255,4 +418,131 @@ private static Settings loadDictionarySettings(Path dir, Settings defaults) thro return defaults; } + + // ==================== CACHE KEY UTILITIES ==================== + + /** + * Builds the cache key for a package-based dictionary. + * Format: "{packageId}:{locale}" (e.g., "pkg-1234:en_US") + * + * @param packageId The package ID + * @param locale The locale + * @return The cache key + */ + public static String buildPackageCacheKey(String packageId, String locale) { + return packageId + CACHE_KEY_SEPARATOR + locale; + } + + /** + * Checks if a cache key is a package-based key (contains separator). + * + * @param cacheKey The cache key to check + * @return true if it's a package-based key, false if it's a traditional locale key + */ + public static boolean isPackageCacheKey(String cacheKey) { + return cacheKey != null && cacheKey.contains(CACHE_KEY_SEPARATOR); + } + + /** + * Invalidates a cached dictionary by its key. + *

    + *
  • Package-based: key format is "{packageId}:{locale}" (e.g., "pkg-1234:en_US")
  • + *
  • Traditional: key is the locale name (e.g., "en_US")
  • + *
+ * + * @param cacheKey The cache key to invalidate + * @return true if dictionary was found and removed, false otherwise + */ + public boolean invalidateDictionary(String cacheKey) { + Dictionary removed = dictionaries.remove(cacheKey); + if (removed != null) { + logger.info("Invalidated hunspell dictionary cache for key: {}", cacheKey); + return true; + } + logger.debug("No cached dictionary found for key: {}", cacheKey); + return false; + } + + /** + * Invalidates all cached dictionaries matching a package ID. + * Useful when a package is updated and all its locales need to be refreshed. + * + *

Matches cache keys with format "{packageId}:{locale}" (e.g., "pkg-1234:en_US") + * + * @param packageId The package ID (e.g., "pkg-1234") + * @return approximate count of invalidated cache entries (may be inaccurate under concurrent modifications) + */ + public int invalidateDictionariesByPackage(String packageId) { + if (Strings.isNullOrEmpty(packageId)) { + logger.warn("Cannot invalidate dictionaries: packageId is null or empty"); + return 0; + } + + String prefix = packageId + CACHE_KEY_SEPARATOR; + int sizeBefore = dictionaries.size(); + dictionaries.keySet().removeIf(key -> key.startsWith(prefix)); + int count = sizeBefore - dictionaries.size(); + + if (count > 0) { + logger.info("Invalidated {} hunspell dictionary cache entries for package: {}", count, packageId); + } else { + logger.debug("No cached dictionaries found for package: {}", packageId); + } + return count; + } + + /** + * Invalidates all cached dictionaries. + * Next access will reload from disk (lazy reload). + * + *

Note: The returned count is approximate in concurrent scenarios, as entries + * may be added or removed between size check and clear. This is acceptable for + * diagnostic purposes. + * + * @return approximate count of invalidated cache entries + */ + public int invalidateAllDictionaries() { + int count = dictionaries.size(); + dictionaries.clear(); + logger.info( + "Invalidated all cached hunspell dictionaries; previous observed cache size was {} (may be approximate due to concurrent updates)", + count + ); + return count; + } + + /** + * Force reloads a dictionary from disk by invalidating cache then loading fresh. + * + * @param packageId The package ID (e.g., "pkg-1234") + * @param locale The locale (e.g., "en_US") + * @return The newly loaded Dictionary + */ + public Dictionary reloadDictionaryFromPackage(String packageId, String locale) { + String cacheKey = buildPackageCacheKey(packageId, locale); + invalidateDictionary(cacheKey); + return getDictionaryFromPackage(packageId, locale); + } + + /** + * Returns all currently cached dictionary keys for diagnostics/debugging. + *

    + *
  • Package-based keys: "{packageId}:{locale}" (e.g., "pkg-1234:en_US")
  • + *
  • Traditional keys: "{locale}" (e.g., "en_US")
  • + *
+ * + * @return Unmodifiable set of cache keys + */ + public Set getCachedDictionaryKeys() { + return Collections.unmodifiableSet(dictionaries.keySet()); + } + + /** + * Returns the count of currently cached dictionaries. + * + * @return count of cached dictionaries + */ + public int getCachedDictionaryCount() { + return dictionaries.size(); + } } diff --git a/server/src/main/java/org/opensearch/node/Node.java b/server/src/main/java/org/opensearch/node/Node.java index f051abfffacf2..d814c07d1b099 100644 --- a/server/src/main/java/org/opensearch/node/Node.java +++ b/server/src/main/java/org/opensearch/node/Node.java @@ -182,6 +182,7 @@ import org.opensearch.indices.SystemIndexDescriptor; import org.opensearch.indices.SystemIndices; import org.opensearch.indices.analysis.AnalysisModule; +import org.opensearch.indices.analysis.HunspellService; import org.opensearch.indices.breaker.BreakerSettings; import org.opensearch.indices.breaker.HierarchyCircuitBreakerService; import org.opensearch.indices.cluster.IndicesClusterStateService; @@ -199,13 +200,10 @@ import org.opensearch.ingest.IngestService; import org.opensearch.ingest.SystemIngestPipelineCache; import org.opensearch.monitor.MonitorService; -import org.opensearch.monitor.NodeRuntimeMetrics; import org.opensearch.monitor.fs.FsHealthService; import org.opensearch.monitor.fs.FsProbe; import org.opensearch.monitor.fs.FsServiceProvider; import org.opensearch.monitor.jvm.JvmInfo; -import org.opensearch.monitor.os.OsProbe; -import org.opensearch.monitor.process.ProcessProbe; import org.opensearch.node.remotestore.RemoteStoreNodeService; import org.opensearch.node.remotestore.RemoteStorePinnedTimestampService; import org.opensearch.node.resource.tracker.NodeResourceUsageTracker; @@ -1038,14 +1036,6 @@ protected Node(final Environment initialEnvironment, Collection clas ); final MonitorService monitorService = new MonitorService(settings, threadPool, fsServiceProvider); - final NodeRuntimeMetrics nodeRuntimeMetrics = new NodeRuntimeMetrics( - metricsRegistry, - monitorService.jvmService(), - ProcessProbe.getInstance(), - OsProbe.getInstance() - ); - resourcesToClose.add(nodeRuntimeMetrics); - final AliasValidator aliasValidator = new AliasValidator(); final ShardLimitValidator shardLimitValidator = new ShardLimitValidator(settings, clusterService, systemIndices); @@ -1173,6 +1163,7 @@ protected Node(final Environment initialEnvironment, Collection clas ).toArray(SearchRequestOperationsListener[]::new) ); + HunspellService hunspellService = analysisModule.getHunspellService(); ActionModule actionModule = new ActionModule( settings, clusterModule.getIndexNameExpressionResolver(), @@ -1186,7 +1177,8 @@ protected Node(final Environment initialEnvironment, Collection clas usageService, systemIndices, identityService, - extensionsManager + extensionsManager, + hunspellService ); modules.add(actionModule); diff --git a/server/src/main/java/org/opensearch/rest/action/admin/indices/RestHunspellCacheInvalidateAction.java b/server/src/main/java/org/opensearch/rest/action/admin/indices/RestHunspellCacheInvalidateAction.java new file mode 100644 index 0000000000000..0bf8f3c499c2f --- /dev/null +++ b/server/src/main/java/org/opensearch/rest/action/admin/indices/RestHunspellCacheInvalidateAction.java @@ -0,0 +1,120 @@ +/* + * 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.rest.action.admin.indices; + +import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInfoAction; +import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInfoRequest; +import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInvalidateAction; +import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInvalidateRequest; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; +import org.opensearch.transport.client.node.NodeClient; + +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static java.util.Arrays.asList; +import static java.util.Collections.unmodifiableList; +import static java.util.Collections.unmodifiableSet; +import static org.opensearch.rest.RestRequest.Method.GET; +import static org.opensearch.rest.RestRequest.Method.POST; + +/** + * REST action for Hunspell dictionary cache management and invalidation. + * + *

Endpoints:

+ *
    + *
  • GET /_hunspell/cache - View all cached dictionary keys (requires cluster:monitor/hunspell/cache permission)
  • + *
  • POST /_hunspell/cache/_invalidate?package_id=xxx - Invalidate all locales for a package
  • + *
  • POST /_hunspell/cache/_invalidate?cache_key=xxx - Invalidate a specific cache entry
  • + *
  • POST /_hunspell/cache/_invalidate_all - Invalidate all cached dictionaries
  • + *
+ * + *

Cache Key Formats:

+ *
    + *
  • Package-based: "{packageId}:{locale}" (e.g., "pkg-1234:en_US")
  • + *
  • Traditional: "{locale}" (e.g., "en_US")
  • + *
+ * + *

Authorization:

+ *
    + *
  • GET operations require "cluster:monitor/hunspell/cache" permission when security is enabled.
  • + *
  • POST operations require "cluster:admin/hunspell/cache/invalidate" permission when security is enabled.
  • + *
+ */ +public class RestHunspellCacheInvalidateAction extends BaseRestHandler { + + public RestHunspellCacheInvalidateAction() {} + + @Override + public List routes() { + return unmodifiableList( + asList( + // GET to view cached keys (requires cluster:monitor/hunspell/cache permission) + new Route(GET, "/_hunspell/cache"), + // POST to invalidate by package_id or cache_key (requires cluster:admin/hunspell/cache/invalidate) + new Route(POST, "/_hunspell/cache/_invalidate"), + // POST to invalidate all (requires cluster:admin/hunspell/cache/invalidate) + new Route(POST, "/_hunspell/cache/_invalidate_all") + ) + ); + } + + @Override + public String getName() { + return "hunspell_cache_invalidate_action"; + } + + @Override + protected Set responseParams() { + Set params = new HashSet<>(); + params.add("package_id"); + params.add("cache_key"); + params.add("locale"); + return unmodifiableSet(params); + } + + @Override + public RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException { + // GET goes through TransportAction for authorization + if (request.method() == RestRequest.Method.GET) { + HunspellCacheInfoRequest infoRequest = new HunspellCacheInfoRequest(); + return channel -> client.execute(HunspellCacheInfoAction.INSTANCE, infoRequest, new RestToXContentListener<>(channel)); + } + + // POST operations go through TransportAction for authorization + HunspellCacheInvalidateRequest invalidateRequest = buildInvalidateRequest(request); + + return channel -> client.execute(HunspellCacheInvalidateAction.INSTANCE, invalidateRequest, new RestToXContentListener<>(channel)); + } + + /** + * Build the transport request from REST parameters. + */ + private HunspellCacheInvalidateRequest buildInvalidateRequest(RestRequest request) { + HunspellCacheInvalidateRequest invalidateRequest = new HunspellCacheInvalidateRequest(); + + if (request.path().endsWith("/_invalidate_all")) { + invalidateRequest.setInvalidateAll(true); + } else { + String packageId = request.param("package_id"); + String locale = request.param("locale"); + String cacheKey = request.param("cache_key"); + + invalidateRequest.setPackageId(packageId); + invalidateRequest.setLocale(locale); + invalidateRequest.setCacheKey(cacheKey); + } + + return invalidateRequest; + } +} diff --git a/server/src/test/java/org/opensearch/action/ActionModuleTests.java b/server/src/test/java/org/opensearch/action/ActionModuleTests.java index 0c1377cb0c6b2..919bd6745e4b3 100644 --- a/server/src/test/java/org/opensearch/action/ActionModuleTests.java +++ b/server/src/test/java/org/opensearch/action/ActionModuleTests.java @@ -144,7 +144,8 @@ public void testSetupRestHandlerContainsKnownBuiltin() throws IOException { usageService, null, new IdentityService(Settings.EMPTY, mock(ThreadPool.class), new ArrayList<>()), - new ExtensionsManager(Set.of(), new IdentityService(Settings.EMPTY, mock(ThreadPool.class), List.of())) + new ExtensionsManager(Set.of(), new IdentityService(Settings.EMPTY, mock(ThreadPool.class), List.of())), + null ); actionModule.initRestHandlers(null); // At this point the easiest way to confirm that a handler is loaded is to try to register another one on top of it and to fail @@ -202,6 +203,7 @@ public String getName() { usageService, null, null, + null, null ); Exception e = expectThrows(IllegalArgumentException.class, () -> actionModule.initRestHandlers(null)); @@ -253,6 +255,7 @@ public List getRestHandlers( usageService, null, null, + null, null ); actionModule.initRestHandlers(null); diff --git a/server/src/test/java/org/opensearch/extensions/rest/RestSendToExtensionActionTests.java b/server/src/test/java/org/opensearch/extensions/rest/RestSendToExtensionActionTests.java index e9c910ea361fb..a5d54376a69ae 100644 --- a/server/src/test/java/org/opensearch/extensions/rest/RestSendToExtensionActionTests.java +++ b/server/src/test/java/org/opensearch/extensions/rest/RestSendToExtensionActionTests.java @@ -123,7 +123,8 @@ public void setup() throws Exception { usageService, null, new IdentityService(Settings.EMPTY, mock(ThreadPool.class), new ArrayList<>()), - new ExtensionsManager(Set.of(), new IdentityService(Settings.EMPTY, mock(ThreadPool.class), List.of())) + new ExtensionsManager(Set.of(), new IdentityService(Settings.EMPTY, mock(ThreadPool.class), List.of())), + null ); identityService = new IdentityService(Settings.EMPTY, mock(ThreadPool.class), new ArrayList<>()); dynamicActionRegistry = actionModule.getDynamicActionRegistry(); diff --git a/server/src/test/java/org/opensearch/index/analysis/HunspellTokenFilterFactoryTests.java b/server/src/test/java/org/opensearch/index/analysis/HunspellTokenFilterFactoryTests.java index 665235b01b88f..122ff87ceea93 100644 --- a/server/src/test/java/org/opensearch/index/analysis/HunspellTokenFilterFactoryTests.java +++ b/server/src/test/java/org/opensearch/index/analysis/HunspellTokenFilterFactoryTests.java @@ -37,10 +37,12 @@ import java.io.IOException; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; public class HunspellTokenFilterFactoryTests extends OpenSearchTestCase { + public void testDedup() throws IOException { Settings settings = Settings.builder() .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) @@ -67,4 +69,384 @@ public void testDedup() throws IOException { hunspellTokenFilter = (HunspellTokenFilterFactory) tokenFilter; assertThat(hunspellTokenFilter.dedup(), is(false)); } + + /** + * Test that ref_path with locale loads dictionary from package directory. + * Expected: config/packages/{ref_path}/hunspell/{locale}/ + */ + public void testRefPathWithLocaleLoadsDictionaryFromPackage() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg") + .put("index.analysis.filter.my_hunspell.locale", "en_US") + .build(); + + TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")); + TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell"); + assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class)); + HunspellTokenFilterFactory hunspellTokenFilter = (HunspellTokenFilterFactory) tokenFilter; + assertThat(hunspellTokenFilter.dedup(), is(true)); + } + + /** + * Test that ref_path without locale throws IllegalArgumentException. + * The locale is required when using ref_path. + */ + public void testRefPathWithoutLocaleThrowsException() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg") + // locale intentionally omitted + .build(); + + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")) + ); + assertThat(e.getMessage(), containsString("locale")); + assertThat(e.getMessage(), containsString("required")); + } + + /** + * Test that ref_path containing "/" throws IllegalArgumentException. + * The ref_path should be just the package ID, not a full path. + */ + public void testRefPathContainingSlashThrowsException() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .put("index.analysis.filter.my_hunspell.ref_path", "packages/test-pkg") + .put("index.analysis.filter.my_hunspell.locale", "en_US") + .build(); + + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")) + ); + assertThat(e.getMessage(), containsString("Only alphanumeric characters")); + } + + /** + * Test that non-existent package directory throws exception. + */ + public void testNonExistentPackageThrowsException() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .put("index.analysis.filter.my_hunspell.ref_path", "non-existent-pkg") + .put("index.analysis.filter.my_hunspell.locale", "en_US") + .build(); + + Exception e = expectThrows( + Exception.class, + () -> AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")) + ); + // The exception message should indicate the package or dictionary was not found + assertThat(e.getMessage(), containsString("non-existent-pkg")); + } + + /** + * Test that non-existent locale in package throws exception. + */ + public void testNonExistentLocaleInPackageThrowsException() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg") + .put("index.analysis.filter.my_hunspell.locale", "fr_FR") // locale doesn't exist in test-pkg + .build(); + + Exception e = expectThrows( + Exception.class, + () -> AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")) + ); + // The exception message should indicate the locale was not found + assertThat(e.getMessage(), containsString("fr_FR")); + } + + /** + * Test that updateable flag works with ref_path for hot-reload support. + * When updateable=true, analysisMode should be SEARCH_TIME. + */ + public void testRefPathWithUpdateableFlag() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg") + .put("index.analysis.filter.my_hunspell.locale", "en_US") + .put("index.analysis.filter.my_hunspell.updateable", true) + .build(); + + TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")); + TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell"); + assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class)); + HunspellTokenFilterFactory hunspellTokenFilter = (HunspellTokenFilterFactory) tokenFilter; + + // When updateable=true, analysis mode should be SEARCH_TIME to enable hot-reload + assertThat(hunspellTokenFilter.getAnalysisMode(), is(AnalysisMode.SEARCH_TIME)); + } + + /** + * Test that without updateable flag, analysis mode is ALL (default). + */ + public void testRefPathWithoutUpdateableFlagDefaultsToAllMode() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg") + .put("index.analysis.filter.my_hunspell.locale", "en_US") + // updateable not set, defaults to false + .build(); + + TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")); + TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell"); + assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class)); + HunspellTokenFilterFactory hunspellTokenFilter = (HunspellTokenFilterFactory) tokenFilter; + + // Without updateable, analysis mode should be ALL + assertThat(hunspellTokenFilter.getAnalysisMode(), is(AnalysisMode.ALL)); + } + + /** + * Test dedup and longestOnly settings work with ref_path. + */ + public void testRefPathWithDedupAndLongestOnly() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg") + .put("index.analysis.filter.my_hunspell.locale", "en_US") + .put("index.analysis.filter.my_hunspell.dedup", false) + .put("index.analysis.filter.my_hunspell.longest_only", true) + .build(); + + TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")); + TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell"); + assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class)); + HunspellTokenFilterFactory hunspellTokenFilter = (HunspellTokenFilterFactory) tokenFilter; + + assertThat(hunspellTokenFilter.dedup(), is(false)); + assertThat(hunspellTokenFilter.longestOnly(), is(true)); + } + + /** + * Test traditional locale-only loading still works (backward compatibility). + */ + public void testTraditionalLocaleOnlyLoadingStillWorks() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .put("index.analysis.filter.my_hunspell.locale", "en_US") + // No ref_path - should load from config/hunspell/en_US/ + .build(); + + TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")); + TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell"); + assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class)); + } + + /** + * Test that missing both ref_path and locale throws exception. + */ + public void testMissingBothRefPathAndLocaleThrowsException() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .build(); + + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")) + ); + assertThat(e.getMessage(), containsString("locale")); + } + + /** + * Test validatePackageIdentifier accepts valid identifiers. + */ + public void testValidatePackageIdentifierAcceptsValid() { + // These should not throw + HunspellTokenFilterFactory.validatePackageIdentifier("pkg-1234", "ref_path"); + HunspellTokenFilterFactory.validatePackageIdentifier("en_US", "locale"); + HunspellTokenFilterFactory.validatePackageIdentifier("my-package-v2", "ref_path"); + HunspellTokenFilterFactory.validatePackageIdentifier("en_US_custom", "locale"); + HunspellTokenFilterFactory.validatePackageIdentifier("a", "ref_path"); // single char + HunspellTokenFilterFactory.validatePackageIdentifier("AB", "ref_path"); // two chars + HunspellTokenFilterFactory.validatePackageIdentifier("pkg.v1", "ref_path"); // dot in middle + } + + /** + * Test validatePackageIdentifier rejects null. + */ + public void testValidatePackageIdentifierRejectsNull() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> HunspellTokenFilterFactory.validatePackageIdentifier(null, "ref_path") + ); + assertThat(e.getMessage(), containsString("null or empty")); + } + + /** + * Test validatePackageIdentifier rejects empty string. + */ + public void testValidatePackageIdentifierRejectsEmpty() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> HunspellTokenFilterFactory.validatePackageIdentifier("", "ref_path") + ); + assertThat(e.getMessage(), containsString("null or empty")); + } + + /** + * Test validatePackageIdentifier rejects slash. + */ + public void testValidatePackageIdentifierRejectsSlash() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> HunspellTokenFilterFactory.validatePackageIdentifier("foo/bar", "ref_path") + ); + assertThat(e.getMessage(), containsString("Only alphanumeric")); + } + + /** + * Test validatePackageIdentifier rejects backslash. + */ + public void testValidatePackageIdentifierRejectsBackslash() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> HunspellTokenFilterFactory.validatePackageIdentifier("foo\\bar", "ref_path") + ); + assertThat(e.getMessage(), containsString("Only alphanumeric")); + } + + /** + * Test validatePackageIdentifier rejects colon (cache key separator). + */ + public void testValidatePackageIdentifierRejectsColon() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> HunspellTokenFilterFactory.validatePackageIdentifier("pkg:inject", "ref_path") + ); + assertThat(e.getMessage(), containsString("Only alphanumeric")); + } + + /** + * Test validatePackageIdentifier rejects leading dot. + */ + public void testValidatePackageIdentifierRejectsLeadingDot() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> HunspellTokenFilterFactory.validatePackageIdentifier(".hidden", "ref_path") + ); + assertThat(e.getMessage(), containsString("Only alphanumeric")); + } + + /** + * Test validatePackageIdentifier rejects trailing dot. + */ + public void testValidatePackageIdentifierRejectsTrailingDot() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> HunspellTokenFilterFactory.validatePackageIdentifier("pkg.", "ref_path") + ); + assertThat(e.getMessage(), containsString("Only alphanumeric")); + } + + /** + * Test validatePackageIdentifier rejects double dots (path traversal). + */ + public void testValidatePackageIdentifierRejectsDoubleDots() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> HunspellTokenFilterFactory.validatePackageIdentifier("foo..bar", "ref_path") + ); + assertThat(e.getMessage(), containsString("Consecutive dots")); + } + + /** + * Test validatePackageIdentifier rejects ".." (pure path traversal). + */ + public void testValidatePackageIdentifierRejectsPureDotDot() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> HunspellTokenFilterFactory.validatePackageIdentifier("..", "ref_path") + ); + assertThat(e.getMessage(), containsString("Only alphanumeric")); + } + + /** + * Test validatePackageIdentifier rejects spaces. + */ + public void testValidatePackageIdentifierRejectsSpaces() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> HunspellTokenFilterFactory.validatePackageIdentifier("my package", "ref_path") + ); + assertThat(e.getMessage(), containsString("Only alphanumeric")); + } + + /** + * Test validatePackageIdentifier rejects special characters. + */ + public void testValidatePackageIdentifierRejectsSpecialChars() { + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> HunspellTokenFilterFactory.validatePackageIdentifier("pkg@v1", "ref_path") + ); + assertThat(e.getMessage(), containsString("Only alphanumeric")); + } + + /** + * Test that create() method produces a valid HunspellStemFilter token stream. + */ + public void testCreateProducesTokenStream() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .put("index.analysis.filter.my_hunspell.ref_path", "test-pkg") + .put("index.analysis.filter.my_hunspell.locale", "en_US") + .build(); + + TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")); + TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell"); + assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class)); + + // Call create() to cover the HunspellStemFilter creation line + org.apache.lucene.analysis.TokenStream ts = tokenFilter.create(new org.apache.lucene.tests.analysis.CannedTokenStream()); + assertNotNull(ts); + } + + /** + * Test that traditional locale create() method also works. + */ + public void testCreateWithTraditionalLocale() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .put("index.analysis.filter.my_hunspell.locale", "en_US") + .build(); + + TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")); + TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell"); + + org.apache.lucene.analysis.TokenStream ts = tokenFilter.create(new org.apache.lucene.tests.analysis.CannedTokenStream()); + assertNotNull(ts); + } + + /** + * Test that 'language' alias works for locale parameter (backward compatibility). + */ + public void testLanguageAliasForLocale() throws IOException { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()) + .put("index.analysis.filter.my_hunspell.type", "hunspell") + .put("index.analysis.filter.my_hunspell.language", "en_US") + .build(); + + TestAnalysis analysis = AnalysisTestsHelper.createTestAnalysisFromSettings(settings, getDataPath("/indices/analyze/conf_dir")); + TokenFilterFactory tokenFilter = analysis.tokenFilter.get("my_hunspell"); + assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class)); + } } diff --git a/server/src/test/java/org/opensearch/indices/analyze/HunspellServiceTests.java b/server/src/test/java/org/opensearch/indices/analyze/HunspellServiceTests.java index f66045898f4a3..25088d74dec6a 100644 --- a/server/src/test/java/org/opensearch/indices/analyze/HunspellServiceTests.java +++ b/server/src/test/java/org/opensearch/indices/analyze/HunspellServiceTests.java @@ -106,4 +106,426 @@ public void testDicWithTwoAffs() { assertEquals("Failed to load hunspell dictionary for locale: en_US", e.getMessage()); assertNull(e.getCause()); } + + // ========== REF_PATH (Package-based Dictionary) TESTS ========== + + public void testGetDictionaryFromPackage() throws Exception { + Path tempDir = createTempDir(); + // Create package directory structure: config/packages/pkg-1234/hunspell/en_US/ + Path packageDir = tempDir.resolve("config").resolve("packages").resolve("pkg-1234").resolve("hunspell").resolve("en_US"); + java.nio.file.Files.createDirectories(packageDir); + + // Create minimal hunspell files + createHunspellFiles(packageDir, "en_US"); + + Settings settings = Settings.builder() + .put(HUNSPELL_LAZY_LOAD.getKey(), randomBoolean()) + .put(Environment.PATH_HOME_SETTING.getKey(), tempDir) + .build(); + + Environment environment = new Environment(settings, tempDir.resolve("config")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + // Test getDictionaryFromPackage + Dictionary dictionary = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US"); + assertThat(dictionary, notNullValue()); + } + + public void testGetDictionaryFromPackageCaching() throws Exception { + Path tempDir = createTempDir(); + Path packageDir = tempDir.resolve("config").resolve("packages").resolve("pkg-1234").resolve("hunspell").resolve("en_US"); + java.nio.file.Files.createDirectories(packageDir); + createHunspellFiles(packageDir, "en_US"); + + Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build(); + + Environment environment = new Environment(settings, tempDir.resolve("config")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + // First call - loads from disk + Dictionary dict1 = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US"); + assertThat(dict1, notNullValue()); + + // Verify cache key is present + assertTrue(hunspellService.getCachedDictionaryKeys().contains("pkg-1234:en_US")); + + // Second call - should return cached instance + Dictionary dict2 = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US"); + assertSame("Should return same cached instance", dict1, dict2); + } + + public void testMultiplePackagesCaching() throws Exception { + Path tempDir = createTempDir(); + + // Create two different packages + Path pkg1Dir = tempDir.resolve("config").resolve("packages").resolve("pkg-1234").resolve("hunspell").resolve("en_US"); + Path pkg2Dir = tempDir.resolve("config").resolve("packages").resolve("pkg-5678").resolve("hunspell").resolve("en_US"); + java.nio.file.Files.createDirectories(pkg1Dir); + java.nio.file.Files.createDirectories(pkg2Dir); + createHunspellFiles(pkg1Dir, "en_US"); + createHunspellFiles(pkg2Dir, "en_US"); + + Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build(); + + Environment environment = new Environment(settings, tempDir.resolve("config")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + // Load both packages + Dictionary dict1 = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US"); + Dictionary dict2 = hunspellService.getDictionaryFromPackage("pkg-5678", "en_US"); + + assertThat(dict1, notNullValue()); + assertThat(dict2, notNullValue()); + assertNotSame("Different packages should have different Dictionary instances", dict1, dict2); + + // Both should be cached with different keys + assertTrue(hunspellService.getCachedDictionaryKeys().contains("pkg-1234:en_US")); + assertTrue(hunspellService.getCachedDictionaryKeys().contains("pkg-5678:en_US")); + assertEquals(2, hunspellService.getCachedDictionaryKeys().size()); + } + + public void testInvalidateDictionaryByPackage() throws Exception { + Path tempDir = createTempDir(); + Path packageDir = tempDir.resolve("config").resolve("packages").resolve("pkg-1234").resolve("hunspell").resolve("en_US"); + java.nio.file.Files.createDirectories(packageDir); + createHunspellFiles(packageDir, "en_US"); + + Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build(); + + Environment environment = new Environment(settings, tempDir.resolve("config")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + // Load dictionary + Dictionary dict1 = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US"); + assertTrue(hunspellService.getCachedDictionaryKeys().contains("pkg-1234:en_US")); + + // Invalidate using full cache key + boolean invalidated = hunspellService.invalidateDictionary("pkg-1234:en_US"); + assertTrue(invalidated); + assertFalse(hunspellService.getCachedDictionaryKeys().contains("pkg-1234:en_US")); + + // Reload after invalidation - should get new instance + Dictionary dict2 = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US"); + assertNotSame("Should be different instance after invalidation", dict1, dict2); + } + + public void testBuildPackageCacheKey() { + assertEquals("pkg-1234:en_US", HunspellService.buildPackageCacheKey("pkg-1234", "en_US")); + assertEquals("my-package:fr_FR", HunspellService.buildPackageCacheKey("my-package", "fr_FR")); + } + + public void testIsPackageCacheKey() { + assertTrue(HunspellService.isPackageCacheKey("pkg-1234:en_US")); + assertTrue(HunspellService.isPackageCacheKey("my-package:fr_FR")); + assertFalse(HunspellService.isPackageCacheKey("en_US")); // Traditional locale-only key + assertFalse(HunspellService.isPackageCacheKey("fr_FR")); + } + + public void testGetDictionaryFromPackageNotFound() throws Exception { + Path tempDir = createTempDir(); + // Don't create the package directory - it doesn't exist + + Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build(); + + Environment environment = new Environment(settings, tempDir.resolve("config")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + IllegalStateException e = expectThrows(IllegalStateException.class, () -> { + hunspellService.getDictionaryFromPackage("nonexistent-pkg", "en_US"); + }); + assertTrue(e.getMessage().contains("Failed to load hunspell dictionary for package")); + } + + public void testMixedCacheKeysTraditionalAndPackage() throws Exception { + Path tempDir = createTempDir(); + + // Create traditional hunspell directory + Path traditionalDir = tempDir.resolve("config").resolve("hunspell").resolve("en_US"); + java.nio.file.Files.createDirectories(traditionalDir); + createHunspellFiles(traditionalDir, "en_US"); + + // Create package directory + Path packageDir = tempDir.resolve("config").resolve("packages").resolve("pkg-1234").resolve("hunspell").resolve("en_US"); + java.nio.file.Files.createDirectories(packageDir); + createHunspellFiles(packageDir, "en_US"); + + Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build(); + + Environment environment = new Environment(settings, tempDir.resolve("config")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + // Load traditional dictionary + Dictionary traditionalDict = hunspellService.getDictionary("en_US"); + // Load package-based dictionary + Dictionary packageDict = hunspellService.getDictionaryFromPackage("pkg-1234", "en_US"); + + assertThat(traditionalDict, notNullValue()); + assertThat(packageDict, notNullValue()); + assertNotSame("Traditional and package dictionaries should be different instances", traditionalDict, packageDict); + + // Both cache keys should exist + assertTrue(hunspellService.getCachedDictionaryKeys().contains("en_US")); // Traditional + assertTrue(hunspellService.getCachedDictionaryKeys().contains("pkg-1234:en_US")); // Package-based + } + + public void testGetDictionaryFromPackageWithNullPackageId() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> hunspellService.getDictionaryFromPackage(null, "en_US") + ); + assertThat(e.getMessage(), org.hamcrest.Matchers.containsString("packageId")); + } + + public void testGetDictionaryFromPackageWithEmptyPackageId() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> hunspellService.getDictionaryFromPackage("", "en_US") + ); + assertThat(e.getMessage(), org.hamcrest.Matchers.containsString("packageId")); + } + + public void testGetDictionaryFromPackageWithNullLocale() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> hunspellService.getDictionaryFromPackage("test-pkg", null) + ); + assertThat(e.getMessage(), org.hamcrest.Matchers.containsString("locale")); + } + + public void testGetDictionaryFromPackageWithEmptyLocale() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> hunspellService.getDictionaryFromPackage("test-pkg", "") + ); + assertThat(e.getMessage(), org.hamcrest.Matchers.containsString("locale")); + } + + public void testInvalidateSingleDictionary() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + hunspellService.getDictionary("en_US"); + assertEquals(1, hunspellService.getCachedDictionaryCount()); + + boolean result = hunspellService.invalidateDictionary("en_US"); + assertTrue(result); + assertEquals(0, hunspellService.getCachedDictionaryCount()); + } + + public void testInvalidateNonExistentDictionary() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + boolean result = hunspellService.invalidateDictionary("nonexistent"); + assertFalse(result); + } + + public void testGetCachedDictionaryCount() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + assertEquals(0, hunspellService.getCachedDictionaryCount()); + + hunspellService.getDictionary("en_US"); + assertEquals(1, hunspellService.getCachedDictionaryCount()); + + hunspellService.getDictionaryFromPackage("test-pkg", "en_US"); + assertEquals(2, hunspellService.getCachedDictionaryCount()); + } + + public void testGetCachedDictionaryKeys() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + assertTrue(hunspellService.getCachedDictionaryKeys().isEmpty()); + + hunspellService.getDictionary("en_US"); + assertTrue(hunspellService.getCachedDictionaryKeys().contains("en_US")); + assertFalse(HunspellService.isPackageCacheKey("en_US")); + + hunspellService.getDictionaryFromPackage("test-pkg", "en_US"); + assertTrue(hunspellService.getCachedDictionaryKeys().contains("test-pkg:en_US")); + assertTrue(HunspellService.isPackageCacheKey("test-pkg:en_US")); + } + + public void testReloadDictionaryFromPackage() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + // Load initially + Dictionary dict1 = hunspellService.getDictionaryFromPackage("test-pkg", "en_US"); + assertThat(dict1, notNullValue()); + + // Reload — should invalidate and reload + Dictionary dict2 = hunspellService.reloadDictionaryFromPackage("test-pkg", "en_US"); + assertThat(dict2, notNullValue()); + + // Cache should still have exactly one entry for this key + assertEquals(1, hunspellService.getCachedDictionaryCount()); + assertTrue(hunspellService.getCachedDictionaryKeys().contains("test-pkg:en_US")); + } + + public void testIsPackageCacheKeyWithNull() { + assertFalse(HunspellService.isPackageCacheKey(null)); + } + + public void testIsPackageCacheKeyWithTraditional() { + assertFalse(HunspellService.isPackageCacheKey("en_US")); + assertFalse(HunspellService.isPackageCacheKey("fr_FR")); + } + + public void testIsPackageCacheKeyWithPackage() { + assertTrue(HunspellService.isPackageCacheKey("pkg-1234:en_US")); + assertTrue(HunspellService.isPackageCacheKey("my-package:fr_FR")); + } + + public void testPackageWithMissingHunspellSubdir() throws Exception { + Path tempDir = createTempDir(); + // Create package dir WITHOUT hunspell subdirectory + Path packageDir = tempDir.resolve("config").resolve("packages").resolve("bad-pkg"); + java.nio.file.Files.createDirectories(packageDir); + + Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build(); + Environment environment = new Environment(settings, tempDir.resolve("config")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("bad-pkg", "en_US")); + assertTrue(e.getMessage().contains("bad-pkg")); + } + + public void testPackageMissingLocaleDir() throws Exception { + Path tempDir = createTempDir(); + // Create package + hunspell dir but no locale subdir + Path hunspellDir = tempDir.resolve("config").resolve("packages").resolve("pkg-empty").resolve("hunspell"); + java.nio.file.Files.createDirectories(hunspellDir); + + Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build(); + Environment environment = new Environment(settings, tempDir.resolve("config")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("pkg-empty", "en_US")); + assertTrue(e.getMessage().contains("en_US") || e.getMessage().contains("pkg-empty")); + } + + public void testPackageMissingAffFile() throws Exception { + Path tempDir = createTempDir(); + Path localeDir = tempDir.resolve("config").resolve("packages").resolve("pkg-noaff").resolve("hunspell").resolve("en_US"); + java.nio.file.Files.createDirectories(localeDir); + // Only create .dic, no .aff + java.nio.file.Files.write(localeDir.resolve("en_US.dic"), java.util.Arrays.asList("1", "test")); + + Settings settings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build(); + Environment environment = new Environment(settings, tempDir.resolve("config")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("pkg-noaff", "en_US")); + assertTrue(e.getMessage().contains("affix") || e.getMessage().contains("pkg-noaff")); + } + + public void testPathTraversalInPackageId() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("..", "en_US")); + assertNotNull(e); + } + + public void testPathTraversalInLocale() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("test-pkg", "../en_US")); + assertNotNull(e); + } + + public void testSlashInPackageId() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("foo/bar", "en_US")); + assertNotNull(e); + } + + public void testBackslashInLocale() throws Exception { + Settings settings = Settings.builder() + .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir()) + .put(HUNSPELL_LAZY_LOAD.getKey(), true) + .build(); + Environment environment = new Environment(settings, getDataPath("/indices/analyze/conf_dir")); + HunspellService hunspellService = new HunspellService(settings, environment, emptyMap()); + + Exception e = expectThrows(Exception.class, () -> hunspellService.getDictionaryFromPackage("test-pkg", "en\\US")); + assertNotNull(e); + } + + // Helper method to create minimal hunspell files for testing + private void createHunspellFiles(Path directory, String locale) throws java.io.IOException { + // Create .aff file + Path affFile = directory.resolve(locale + ".aff"); + java.nio.file.Files.write(affFile, java.util.Arrays.asList("SET UTF-8", "SFX S Y 1", "SFX S 0 s .")); + + // Create .dic file + Path dicFile = directory.resolve(locale + ".dic"); + java.nio.file.Files.write(dicFile, java.util.Arrays.asList("3", "test/S", "word/S", "hello")); + } } diff --git a/server/src/test/java/org/opensearch/rest/action/admin/indices/RestHunspellCacheInvalidateActionTests.java b/server/src/test/java/org/opensearch/rest/action/admin/indices/RestHunspellCacheInvalidateActionTests.java new file mode 100644 index 0000000000000..1c24afddcf648 --- /dev/null +++ b/server/src/test/java/org/opensearch/rest/action/admin/indices/RestHunspellCacheInvalidateActionTests.java @@ -0,0 +1,307 @@ +/* + * 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.rest.action.admin.indices; + +import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInvalidateRequest; +import org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInvalidateResponse; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.indices.analysis.HunspellService; +import org.opensearch.rest.RestRequest; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.test.rest.FakeRestRequest; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; + +/** + * Tests for {@link RestHunspellCacheInvalidateAction} + */ +public class RestHunspellCacheInvalidateActionTests extends OpenSearchTestCase { + + // ==================== Route Tests ==================== + + public void testRoutes() { + RestHunspellCacheInvalidateAction action = new RestHunspellCacheInvalidateAction(); + + List routes = action.routes(); + assertThat(routes, hasSize(3)); + + // Verify GET route + assertTrue(routes.stream().anyMatch(r -> r.getMethod() == RestRequest.Method.GET && r.getPath().equals("/_hunspell/cache"))); + + // Verify POST invalidate route + assertTrue( + routes.stream().anyMatch(r -> r.getMethod() == RestRequest.Method.POST && r.getPath().equals("/_hunspell/cache/_invalidate")) + ); + + // Verify POST invalidate_all route + assertTrue( + routes.stream() + .anyMatch(r -> r.getMethod() == RestRequest.Method.POST && r.getPath().equals("/_hunspell/cache/_invalidate_all")) + ); + } + + public void testHandlerName() { + RestHunspellCacheInvalidateAction action = new RestHunspellCacheInvalidateAction(); + assertThat(action.getName(), equalTo("hunspell_cache_invalidate_action")); + } + + // ==================== Request Building Tests ==================== + + public void testBuildRequestWithPackageIdOnly() { + HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest(); + request.setPackageId("pkg-12345"); + + assertThat(request.getPackageId(), equalTo("pkg-12345")); + assertThat(request.getLocale(), nullValue()); + assertThat(request.getCacheKey(), nullValue()); + assertThat(request.isInvalidateAll(), equalTo(false)); + assertNull(request.validate()); + } + + public void testBuildRequestWithPackageIdAndLocale() { + HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest(); + request.setPackageId("pkg-12345"); + request.setLocale("en_US"); + + assertThat(request.getPackageId(), equalTo("pkg-12345")); + assertThat(request.getLocale(), equalTo("en_US")); + assertThat(request.getCacheKey(), nullValue()); + assertThat(request.isInvalidateAll(), equalTo(false)); + assertNull(request.validate()); + } + + public void testBuildRequestWithCacheKey() { + HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest(); + request.setCacheKey("pkg-12345:en_US"); + + assertThat(request.getPackageId(), nullValue()); + assertThat(request.getLocale(), nullValue()); + assertThat(request.getCacheKey(), equalTo("pkg-12345:en_US")); + assertThat(request.isInvalidateAll(), equalTo(false)); + assertNull(request.validate()); + } + + public void testBuildRequestWithInvalidateAll() { + HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest(); + request.setInvalidateAll(true); + + assertThat(request.getPackageId(), nullValue()); + assertThat(request.getLocale(), nullValue()); + assertThat(request.getCacheKey(), nullValue()); + assertThat(request.isInvalidateAll(), equalTo(true)); + assertNull(request.validate()); + } + + public void testRequestValidationFailsWhenNoParametersProvided() { + HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest(); + // No parameters set + + assertNotNull(request.validate()); + assertThat(request.validate().getMessage(), containsString("package_id")); + } + + // ==================== Request Serialization Tests ==================== + + public void testRequestSerialization() throws IOException { + HunspellCacheInvalidateRequest original = new HunspellCacheInvalidateRequest(); + original.setPackageId("pkg-test"); + original.setLocale("de_DE"); + original.setCacheKey("some-key"); + original.setInvalidateAll(false); + + BytesStreamOutput out = new BytesStreamOutput(); + original.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + HunspellCacheInvalidateRequest deserialized = new HunspellCacheInvalidateRequest(in); + + assertThat(deserialized.getPackageId(), equalTo(original.getPackageId())); + assertThat(deserialized.getLocale(), equalTo(original.getLocale())); + assertThat(deserialized.getCacheKey(), equalTo(original.getCacheKey())); + assertThat(deserialized.isInvalidateAll(), equalTo(original.isInvalidateAll())); + } + + public void testRequestSerializationWithNullValues() throws IOException { + HunspellCacheInvalidateRequest original = new HunspellCacheInvalidateRequest(); + original.setInvalidateAll(true); + // packageId, locale, cacheKey are null + + BytesStreamOutput out = new BytesStreamOutput(); + original.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + HunspellCacheInvalidateRequest deserialized = new HunspellCacheInvalidateRequest(in); + + assertThat(deserialized.getPackageId(), nullValue()); + assertThat(deserialized.getLocale(), nullValue()); + assertThat(deserialized.getCacheKey(), nullValue()); + assertThat(deserialized.isInvalidateAll(), equalTo(true)); + } + + // ==================== Response Tests ==================== + + public void testResponseSerialization() throws IOException { + HunspellCacheInvalidateResponse original = new HunspellCacheInvalidateResponse(true, 5, "pkg-123", "en_US", "pkg-123:en_US"); + + BytesStreamOutput out = new BytesStreamOutput(); + original.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + HunspellCacheInvalidateResponse deserialized = new HunspellCacheInvalidateResponse(in); + + assertThat(deserialized.isAcknowledged(), equalTo(true)); + assertThat(deserialized.getInvalidatedCount(), equalTo(5)); + assertThat(deserialized.getPackageId(), equalTo("pkg-123")); + assertThat(deserialized.getLocale(), equalTo("en_US")); + assertThat(deserialized.getCacheKey(), equalTo("pkg-123:en_US")); + } + + public void testResponseSerializationWithNullValues() throws IOException { + HunspellCacheInvalidateResponse original = new HunspellCacheInvalidateResponse(true, 10, null, null, null); + + BytesStreamOutput out = new BytesStreamOutput(); + original.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + HunspellCacheInvalidateResponse deserialized = new HunspellCacheInvalidateResponse(in); + + assertThat(deserialized.isAcknowledged(), equalTo(true)); + assertThat(deserialized.getInvalidatedCount(), equalTo(10)); + assertThat(deserialized.getPackageId(), nullValue()); + assertThat(deserialized.getLocale(), nullValue()); + assertThat(deserialized.getCacheKey(), nullValue()); + } + + public void testResponseToXContent() throws IOException { + HunspellCacheInvalidateResponse response = new HunspellCacheInvalidateResponse(true, 3, "pkg-abc", "fr_FR", "pkg-abc:fr_FR"); + + XContentBuilder builder = XContentFactory.jsonBuilder(); + response.toXContent(builder, null); + String json = BytesReference.bytes(builder).utf8ToString(); + + assertThat(json, containsString("\"acknowledged\":true")); + assertThat(json, containsString("\"invalidated_count\":3")); + assertThat(json, containsString("\"package_id\":\"pkg-abc\"")); + assertThat(json, containsString("\"locale\":\"fr_FR\"")); + assertThat(json, containsString("\"cache_key\":\"pkg-abc:fr_FR\"")); + } + + public void testResponseToXContentIncludesNullValuesForConsistentSchema() throws IOException { + HunspellCacheInvalidateResponse response = new HunspellCacheInvalidateResponse(true, 7, null, null, null); + + XContentBuilder builder = XContentFactory.jsonBuilder(); + response.toXContent(builder, null); + String json = BytesReference.bytes(builder).utf8ToString(); + + assertThat(json, containsString("\"acknowledged\":true")); + assertThat(json, containsString("\"invalidated_count\":7")); + // Null values should still be present for consistent response schema + assertThat(json, containsString("\"package_id\":null")); + assertThat(json, containsString("\"locale\":null")); + assertThat(json, containsString("\"cache_key\":null")); + } + + // ==================== REST Parameter Parsing Tests ==================== + + public void testRestParamsPackageIdOnly() { + Map params = new HashMap<>(); + params.put("package_id", "my-pkg"); + + RestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry()).withParams(params) + .withPath("/_hunspell/cache/_invalidate") + .withMethod(RestRequest.Method.POST) + .build(); + + assertThat(restRequest.param("package_id"), equalTo("my-pkg")); + assertThat(restRequest.param("locale"), nullValue()); + assertThat(restRequest.param("cache_key"), nullValue()); + } + + public void testRestParamsPackageIdAndLocale() { + Map params = new HashMap<>(); + params.put("package_id", "my-pkg"); + params.put("locale", "es_ES"); + + RestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry()).withParams(params) + .withPath("/_hunspell/cache/_invalidate") + .withMethod(RestRequest.Method.POST) + .build(); + + assertThat(restRequest.param("package_id"), equalTo("my-pkg")); + assertThat(restRequest.param("locale"), equalTo("es_ES")); + } + + public void testRestParamsCacheKey() { + Map params = new HashMap<>(); + params.put("cache_key", "pkg-xyz:it_IT"); + + RestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry()).withParams(params) + .withPath("/_hunspell/cache/_invalidate") + .withMethod(RestRequest.Method.POST) + .build(); + + assertThat(restRequest.param("cache_key"), equalTo("pkg-xyz:it_IT")); + } + + public void testRestPathInvalidateAll() { + RestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry()).withPath("/_hunspell/cache/_invalidate_all") + .withMethod(RestRequest.Method.POST) + .build(); + + assertTrue(restRequest.path().endsWith("/_invalidate_all")); + } + + // ==================== HunspellService Cache Key Helper Tests ==================== + + public void testBuildPackageCacheKey() { + String key = HunspellService.buildPackageCacheKey("pkg-123", "en_US"); + assertThat(key, equalTo("pkg-123:en_US")); + } + + public void testIsPackageCacheKey() { + assertTrue(HunspellService.isPackageCacheKey("pkg-123:en_US")); + assertTrue(HunspellService.isPackageCacheKey("some-id:de_DE")); + assertFalse(HunspellService.isPackageCacheKey("en_US")); + assertFalse(HunspellService.isPackageCacheKey("simple_locale")); + } + + // ==================== Action Name Test ==================== + + public void testActionName() { + assertThat( + org.opensearch.action.admin.indices.cache.hunspell.HunspellCacheInvalidateAction.NAME, + equalTo("cluster:admin/hunspell/cache/invalidate") + ); + } + + // ==================== Response Params Test ==================== + + public void testResponseParams() { + RestHunspellCacheInvalidateAction action = new RestHunspellCacheInvalidateAction(); + + Set params = action.responseParams(); + assertTrue(params.contains("package_id")); + assertTrue(params.contains("cache_key")); + assertTrue(params.contains("locale")); + assertThat(params.size(), equalTo(3)); + } +} diff --git a/server/src/test/resources/indices/analyze/conf_dir/packages/test-pkg/hunspell/en_US/en_US.aff b/server/src/test/resources/indices/analyze/conf_dir/packages/test-pkg/hunspell/en_US/en_US.aff new file mode 100644 index 0000000000000..2ddd985437187 --- /dev/null +++ b/server/src/test/resources/indices/analyze/conf_dir/packages/test-pkg/hunspell/en_US/en_US.aff @@ -0,0 +1,201 @@ +SET ISO8859-1 +TRY esianrtolcdugmphbyfvkwzESIANRTOLCDUGMPHBYFVKWZ' +NOSUGGEST ! + +# ordinal numbers +COMPOUNDMIN 1 +# only in compounds: 1th, 2th, 3th +ONLYINCOMPOUND c +# compound rules: +# 1. [0-9]*1[0-9]th (10th, 11th, 12th, 56714th, etc.) +# 2. [0-9]*[02-9](1st|2nd|3rd|[4-9]th) (21st, 22nd, 123rd, 1234th, etc.) +COMPOUNDRULE 2 +COMPOUNDRULE n*1t +COMPOUNDRULE n*mp +WORDCHARS 0123456789 + +PFX A Y 1 +PFX A 0 re . + +PFX I Y 1 +PFX I 0 in . + +PFX U Y 1 +PFX U 0 un . + +PFX C Y 1 +PFX C 0 de . + +PFX E Y 1 +PFX E 0 dis . + +PFX F Y 1 +PFX F 0 con . + +PFX K Y 1 +PFX K 0 pro . + +SFX V N 2 +SFX V e ive e +SFX V 0 ive [^e] + +SFX N Y 3 +SFX N e ion e +SFX N y ication y +SFX N 0 en [^ey] + +SFX X Y 3 +SFX X e ions e +SFX X y ications y +SFX X 0 ens [^ey] + +SFX H N 2 +SFX H y ieth y +SFX H 0 th [^y] + +SFX Y Y 1 +SFX Y 0 ly . + +SFX G Y 2 +SFX G e ing e +SFX G 0 ing [^e] + +SFX J Y 2 +SFX J e ings e +SFX J 0 ings [^e] + +SFX D Y 4 +SFX D 0 d e +SFX D y ied [^aeiou]y +SFX D 0 ed [^ey] +SFX D 0 ed [aeiou]y + +SFX T N 4 +SFX T 0 st e +SFX T y iest [^aeiou]y +SFX T 0 est [aeiou]y +SFX T 0 est [^ey] + +SFX R Y 4 +SFX R 0 r e +SFX R y ier [^aeiou]y +SFX R 0 er [aeiou]y +SFX R 0 er [^ey] + +SFX Z Y 4 +SFX Z 0 rs e +SFX Z y iers [^aeiou]y +SFX Z 0 ers [aeiou]y +SFX Z 0 ers [^ey] + +SFX S Y 4 +SFX S y ies [^aeiou]y +SFX S 0 s [aeiou]y +SFX S 0 es [sxzh] +SFX S 0 s [^sxzhy] + +SFX P Y 3 +SFX P y iness [^aeiou]y +SFX P 0 ness [aeiou]y +SFX P 0 ness [^y] + +SFX M Y 1 +SFX M 0 's . + +SFX B Y 3 +SFX B 0 able [^aeiou] +SFX B 0 able ee +SFX B e able [^aeiou]e + +SFX L Y 1 +SFX L 0 ment . + +REP 88 +REP a ei +REP ei a +REP a ey +REP ey a +REP ai ie +REP ie ai +REP are air +REP are ear +REP are eir +REP air are +REP air ere +REP ere air +REP ere ear +REP ere eir +REP ear are +REP ear air +REP ear ere +REP eir are +REP eir ere +REP ch te +REP te ch +REP ch ti +REP ti ch +REP ch tu +REP tu ch +REP ch s +REP s ch +REP ch k +REP k ch +REP f ph +REP ph f +REP gh f +REP f gh +REP i igh +REP igh i +REP i uy +REP uy i +REP i ee +REP ee i +REP j di +REP di j +REP j gg +REP gg j +REP j ge +REP ge j +REP s ti +REP ti s +REP s ci +REP ci s +REP k cc +REP cc k +REP k qu +REP qu k +REP kw qu +REP o eau +REP eau o +REP o ew +REP ew o +REP oo ew +REP ew oo +REP ew ui +REP ui ew +REP oo ui +REP ui oo +REP ew u +REP u ew +REP oo u +REP u oo +REP u oe +REP oe u +REP u ieu +REP ieu u +REP ue ew +REP ew ue +REP uff ough +REP oo ieu +REP ieu oo +REP ier ear +REP ear ier +REP ear air +REP air ear +REP w qu +REP qu w +REP z ss +REP ss z +REP shun tion +REP shun sion +REP shun cion diff --git a/server/src/test/resources/indices/analyze/conf_dir/packages/test-pkg/hunspell/en_US/en_US.dic b/server/src/test/resources/indices/analyze/conf_dir/packages/test-pkg/hunspell/en_US/en_US.dic new file mode 100644 index 0000000000000..d278da593c573 --- /dev/null +++ b/server/src/test/resources/indices/analyze/conf_dir/packages/test-pkg/hunspell/en_US/en_US.dic @@ -0,0 +1,106 @@ +100 +test/S +word/S +hello +world/S +example/S +package/S +dictionary/S +hunspell +analysis +search/S +index/S +document/S +cluster/S +node/S +shard/S +replica/S +query/S +filter/S +token/S +analyzer/S +mapping/S +setting/S +request/S +response/S +action/S +cache/S +locale +config +plugin/S +module/S +server/S +client/S +service/S +manager/S +factory/S +handler/S +transport/S +network/S +thread/S +pool/S +memory +storage +engine/S +snapshot/S +restore +backup/S +monitor/S +metric/S +health +status +version/S +update/S +delete +create +read +write +merge +refresh +flush +commit +recover +replicate +allocate +balance +route +forward +ingest +process +transform +validate +authenticate +authorize +encrypt +decrypt +compress +decompress +serialize +deserialize +compute +execute +invoke +dispatch +publish +subscribe +notify +broadcast +stream +buffer/S +pipeline/S +workflow/S +template/S +pattern/S +schema/S +format/S +protocol/S +endpoint/S +interface/S +abstract +concrete +virtual +static +dynamic +public +private +secure \ No newline at end of file