Skip to content

Commit 20ba2c0

Browse files
committed
feat(hunspell): Add ref_path support and cache invalidation API
- Add ref_path parameter for package-based dictionary loading - Load from config/packages/{packageId}/hunspell/{locale}/ - Add cache info API: GET /_hunspell/cache (cluster:monitor/hunspell/cache) - Add cache invalidation API: POST /_hunspell/cache/_invalidate (cluster:admin/hunspell/cache/invalidate) - Support invalidation by package_id, locale, cache_key, or invalidate_all - Add security validation (path traversal, separator injection, null bytes) - Add updateable flag for hot-reload via _reload_search_analyzers - Use Strings.hasText() and Strings.isNullOrEmpty() for validation consistency - Consistent response schema with all fields always present - Add unit tests, REST handler tests, and integration tests Signed-off-by: shayush622 <ayush5267@gmail.com>
1 parent c1dd00b commit 20ba2c0

File tree

16 files changed

+369
-199
lines changed

16 files changed

+369
-199
lines changed
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*/
8+
9+
package org.opensearch.action.admin.indices.cache.hunspell;
10+
11+
import org.opensearch.common.settings.Settings;
12+
import org.opensearch.indices.analysis.HunspellService;
13+
import org.opensearch.test.OpenSearchIntegTestCase;
14+
import org.opensearch.test.OpenSearchIntegTestCase.ClusterScope;
15+
16+
import java.io.IOException;
17+
import java.nio.charset.StandardCharsets;
18+
import java.nio.file.Files;
19+
import java.nio.file.Path;
20+
21+
import static org.hamcrest.Matchers.equalTo;
22+
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
23+
24+
/**
25+
* Integration tests for hunspell cache info and invalidation APIs.
26+
*
27+
* <p>Tests the full REST→Transport→HunspellService flow on a real cluster:
28+
* <ul>
29+
* <li>GET /_hunspell/cache (HunspellCacheInfoAction)</li>
30+
* <li>POST /_hunspell/cache/_invalidate (HunspellCacheInvalidateAction)</li>
31+
* </ul>
32+
*/
33+
@ClusterScope(scope = OpenSearchIntegTestCase.Scope.TEST)
34+
public class HunspellCacheIT extends OpenSearchIntegTestCase {
35+
36+
@Override
37+
protected Settings nodeSettings(int nodeOrdinal) {
38+
// Enable lazy loading so dictionaries are only loaded on-demand
39+
return Settings.builder().put(super.nodeSettings(nodeOrdinal)).put(HunspellService.HUNSPELL_LAZY_LOAD.getKey(), true).build();
40+
}
41+
42+
/**
43+
* Sets up hunspell dictionary files in the node's config directory.
44+
* Creates both traditional and package-based dictionaries.
45+
*/
46+
private void setupDictionaries(Path configDir) throws IOException {
47+
// Traditional dictionary: config/hunspell/en_US/
48+
Path traditionalDir = configDir.resolve("hunspell").resolve("en_US");
49+
Files.createDirectories(traditionalDir);
50+
Files.write(traditionalDir.resolve("en_US.aff"), "SET UTF-8\n".getBytes(StandardCharsets.UTF_8));
51+
Files.write(traditionalDir.resolve("en_US.dic"), "1\nhello\n".getBytes(StandardCharsets.UTF_8));
52+
53+
// Package-based dictionary: config/packages/test-pkg/hunspell/en_US/
54+
Path packageDir = configDir.resolve("packages").resolve("test-pkg").resolve("hunspell").resolve("en_US");
55+
Files.createDirectories(packageDir);
56+
Files.write(packageDir.resolve("en_US.aff"), "SET UTF-8\n".getBytes(StandardCharsets.UTF_8));
57+
Files.write(packageDir.resolve("en_US.dic"), "1\nworld\n".getBytes(StandardCharsets.UTF_8));
58+
}
59+
60+
// ==================== Cache Info Tests ====================
61+
62+
public void testCacheInfoReturnsEmptyWhenNoDictionariesLoaded() {
63+
HunspellCacheInfoResponse response = client().execute(HunspellCacheInfoAction.INSTANCE, new HunspellCacheInfoRequest()).actionGet();
64+
65+
assertThat(response.getTotalCachedCount(), equalTo(0));
66+
assertThat(response.getPackageBasedCount(), equalTo(0));
67+
assertThat(response.getTraditionalLocaleCount(), equalTo(0));
68+
assertTrue(response.getPackageBasedKeys().isEmpty());
69+
assertTrue(response.getTraditionalLocaleKeys().isEmpty());
70+
}
71+
72+
// ==================== Cache Invalidation Tests ====================
73+
74+
public void testInvalidateAllOnEmptyCache() {
75+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
76+
request.setInvalidateAll(true);
77+
78+
HunspellCacheInvalidateResponse response = client().execute(HunspellCacheInvalidateAction.INSTANCE, request).actionGet();
79+
80+
assertTrue(response.isAcknowledged());
81+
assertThat(response.getInvalidatedCount(), equalTo(0));
82+
}
83+
84+
public void testInvalidateByPackageIdOnEmptyCache() {
85+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
86+
request.setPackageId("nonexistent-pkg");
87+
88+
HunspellCacheInvalidateResponse response = client().execute(HunspellCacheInvalidateAction.INSTANCE, request).actionGet();
89+
90+
assertTrue(response.isAcknowledged());
91+
assertThat(response.getInvalidatedCount(), equalTo(0));
92+
assertThat(response.getPackageId(), equalTo("nonexistent-pkg"));
93+
}
94+
95+
public void testInvalidateByCacheKeyOnEmptyCache() {
96+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
97+
request.setCacheKey("nonexistent-key");
98+
99+
HunspellCacheInvalidateResponse response = client().execute(HunspellCacheInvalidateAction.INSTANCE, request).actionGet();
100+
101+
assertTrue(response.isAcknowledged());
102+
assertThat(response.getInvalidatedCount(), equalTo(0));
103+
}
104+
105+
// ==================== Request Validation Tests ====================
106+
107+
public void testInvalidateRequestValidationFailsWithNoParameters() {
108+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
109+
// No parameters set — should fail validation
110+
111+
assertNotNull(request.validate());
112+
}
113+
114+
public void testInvalidateRequestValidationFailsWithConflictingParameters() {
115+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
116+
request.setInvalidateAll(true);
117+
request.setPackageId("some-pkg");
118+
119+
assertNotNull(request.validate());
120+
}
121+
122+
public void testInvalidateRequestValidationFailsWithEmptyPackageId() {
123+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
124+
request.setPackageId(" ");
125+
126+
assertNotNull(request.validate());
127+
}
128+
129+
public void testInvalidateRequestValidationPassesWithPackageIdOnly() {
130+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
131+
request.setPackageId("valid-pkg");
132+
133+
assertNull(request.validate());
134+
}
135+
136+
public void testInvalidateRequestValidationPassesWithPackageIdAndLocale() {
137+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
138+
request.setPackageId("valid-pkg");
139+
request.setLocale("en_US");
140+
141+
assertNull(request.validate());
142+
}
143+
144+
public void testInvalidateRequestValidationFailsWithLocaleWithoutPackageId() {
145+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
146+
request.setLocale("en_US");
147+
148+
assertNotNull(request.validate());
149+
}
150+
151+
// ==================== Response Schema Tests ====================
152+
153+
public void testInvalidateResponseAlwaysIncludesAllFields() {
154+
HunspellCacheInvalidateRequest request = new HunspellCacheInvalidateRequest();
155+
request.setInvalidateAll(true);
156+
157+
HunspellCacheInvalidateResponse response = client().execute(HunspellCacheInvalidateAction.INSTANCE, request).actionGet();
158+
159+
// Response should always include these fields for consistent schema
160+
assertTrue(response.isAcknowledged());
161+
assertThat(response.getInvalidatedCount(), greaterThanOrEqualTo(0));
162+
// Null fields should still be accessible (consistent schema)
163+
assertNull(response.getPackageId());
164+
assertNull(response.getLocale());
165+
assertNull(response.getCacheKey());
166+
}
167+
168+
public void testCacheInfoResponseSchema() {
169+
HunspellCacheInfoResponse response = client().execute(HunspellCacheInfoAction.INSTANCE, new HunspellCacheInfoRequest()).actionGet();
170+
171+
// Verify response schema has all expected fields
172+
assertThat(response.getTotalCachedCount(), greaterThanOrEqualTo(0));
173+
assertThat(response.getPackageBasedCount(), greaterThanOrEqualTo(0));
174+
assertThat(response.getTraditionalLocaleCount(), greaterThanOrEqualTo(0));
175+
assertNotNull(response.getPackageBasedKeys());
176+
assertNotNull(response.getTraditionalLocaleKeys());
177+
}
178+
}

server/src/main/java/org/opensearch/action/ActionModule.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@
337337
import org.opensearch.identity.IdentityService;
338338
import org.opensearch.index.seqno.RetentionLeaseActions;
339339
import org.opensearch.indices.SystemIndices;
340+
import org.opensearch.indices.analysis.HunspellService;
340341
import org.opensearch.persistent.CompletionPersistentTaskAction;
341342
import org.opensearch.persistent.RemovePersistentTaskAction;
342343
import org.opensearch.persistent.StartPersistentTaskAction;
@@ -423,6 +424,7 @@
423424
import org.opensearch.rest.action.admin.indices.RestGetIngestionStateAction;
424425
import org.opensearch.rest.action.admin.indices.RestGetMappingAction;
425426
import org.opensearch.rest.action.admin.indices.RestGetSettingsAction;
427+
import org.opensearch.rest.action.admin.indices.RestHunspellCacheInvalidateAction;
426428
import org.opensearch.rest.action.admin.indices.RestIndexDeleteAliasesAction;
427429
import org.opensearch.rest.action.admin.indices.RestIndexPutAliasAction;
428430
import org.opensearch.rest.action.admin.indices.RestIndicesAliasesAction;
@@ -446,7 +448,6 @@
446448
import org.opensearch.rest.action.admin.indices.RestSimulateTemplateAction;
447449
import org.opensearch.rest.action.admin.indices.RestSyncedFlushAction;
448450
import org.opensearch.rest.action.admin.indices.RestUpdateSettingsAction;
449-
import org.opensearch.rest.action.admin.indices.RestHunspellCacheInvalidateAction;
450451
import org.opensearch.rest.action.admin.indices.RestUpgradeAction;
451452
import org.opensearch.rest.action.admin.indices.RestUpgradeStatusAction;
452453
import org.opensearch.rest.action.admin.indices.RestValidateQueryAction;
@@ -492,7 +493,6 @@
492493
import org.opensearch.rest.action.list.RestIndicesListAction;
493494
import org.opensearch.rest.action.list.RestListAction;
494495
import org.opensearch.rest.action.list.RestShardsListAction;
495-
import org.opensearch.indices.analysis.HunspellService;
496496
import org.opensearch.rest.action.search.RestClearScrollAction;
497497
import org.opensearch.rest.action.search.RestCountAction;
498498
import org.opensearch.rest.action.search.RestCreatePitAction;
@@ -1124,7 +1124,7 @@ protected void configure() {
11241124
bind(DynamicActionRegistry.class).toInstance(dynamicActionRegistry);
11251125

11261126
bind(ResponseLimitSettings.class).toInstance(responseLimitSettings);
1127-
1127+
11281128
// Bind HunspellService for TransportHunspellCacheInvalidateAction injection
11291129
if (hunspellService != null) {
11301130
bind(HunspellService.class).toInstance(hunspellService);

server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoAction.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
/**
1414
* Action for retrieving Hunspell cache information.
15-
*
15+
*
1616
* <p>This action requires "cluster:monitor/hunspell/cache" permission when security is enabled.
1717
*
1818
* @opensearch.internal
@@ -25,4 +25,4 @@ public class HunspellCacheInfoAction extends ActionType<HunspellCacheInfoRespons
2525
private HunspellCacheInfoAction() {
2626
super(NAME, HunspellCacheInfoResponse::new);
2727
}
28-
}
28+
}

server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoRequest.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@
2222
*/
2323
public class HunspellCacheInfoRequest extends ActionRequest {
2424

25-
public HunspellCacheInfoRequest() {
26-
}
25+
public HunspellCacheInfoRequest() {}
2726

2827
public HunspellCacheInfoRequest(StreamInput in) throws IOException {
2928
super(in);
@@ -38,4 +37,4 @@ public void writeTo(StreamOutput out) throws IOException {
3837
public ActionRequestValidationException validate() {
3938
return null;
4039
}
41-
}
40+
}

server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInfoResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,4 @@ public Set<String> getPackageBasedKeys() {
9494
public Set<String> getTraditionalLocaleKeys() {
9595
return traditionalLocaleKeys;
9696
}
97-
}
97+
}

server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateRequest.java

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
/**
2020
* Request for Hunspell cache invalidation.
21-
*
21+
*
2222
* <p>Supports three modes:
2323
* <ul>
2424
* <li>Invalidate by package_id (clears all locales for a package)</li>
@@ -36,8 +36,7 @@ public class HunspellCacheInvalidateRequest extends ActionRequest {
3636
private String cacheKey;
3737
private boolean invalidateAll;
3838

39-
public HunspellCacheInvalidateRequest() {
40-
}
39+
public HunspellCacheInvalidateRequest() {}
4140

4241
public HunspellCacheInvalidateRequest(StreamInput in) throws IOException {
4342
super(in);
@@ -59,7 +58,7 @@ public void writeTo(StreamOutput out) throws IOException {
5958
@Override
6059
public ActionRequestValidationException validate() {
6160
ActionRequestValidationException e = null;
62-
61+
6362
// Reject empty/blank strings with clear error messages
6463
if (packageId != null && !Strings.hasText(packageId)) {
6564
e = new ActionRequestValidationException();
@@ -73,18 +72,18 @@ public ActionRequestValidationException validate() {
7372
if (e == null) e = new ActionRequestValidationException();
7473
e.addValidationError("'cache_key' cannot be empty or blank");
7574
}
76-
75+
7776
// If any blank validation errors, return early
7877
if (e != null) {
7978
return e;
8079
}
81-
80+
8281
// Count how many modes are specified
8382
int modeCount = 0;
8483
if (invalidateAll) modeCount++;
8584
if (packageId != null) modeCount++;
8685
if (cacheKey != null) modeCount++;
87-
86+
8887
if (modeCount == 0) {
8988
e = new ActionRequestValidationException();
9089
e.addValidationError("Either 'package_id', 'cache_key', or 'invalidate_all' must be specified");
@@ -96,13 +95,13 @@ public ActionRequestValidationException validate() {
9695
e.addValidationError("Only one of 'package_id' or 'cache_key' can be specified, not both");
9796
}
9897
}
99-
98+
10099
// locale is only valid with package_id
101100
if (locale != null && packageId == null) {
102101
if (e == null) e = new ActionRequestValidationException();
103102
e.addValidationError("'locale' can only be specified together with 'package_id'");
104103
}
105-
104+
106105
return e;
107106
}
108107

@@ -141,4 +140,4 @@ public HunspellCacheInvalidateRequest setInvalidateAll(boolean invalidateAll) {
141140
this.invalidateAll = invalidateAll;
142141
return this;
143142
}
144-
}
143+
}

server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/HunspellCacheInvalidateResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,4 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
8686
builder.endObject();
8787
return builder;
8888
}
89-
}
89+
}

server/src/main/java/org/opensearch/action/admin/indices/cache/hunspell/TransportHunspellCacheInfoAction.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
/**
2323
* Transport action for retrieving Hunspell cache information.
24-
*
24+
*
2525
* <p>Requires "cluster:monitor/hunspell/cache" permission when security is enabled.
2626
*
2727
* @opensearch.internal
@@ -44,29 +44,29 @@ public TransportHunspellCacheInfoAction(
4444
protected void doExecute(Task task, HunspellCacheInfoRequest request, ActionListener<HunspellCacheInfoResponse> listener) {
4545
try {
4646
Set<String> cachedKeys = hunspellService.getCachedDictionaryKeys();
47-
47+
4848
Set<String> packageKeys = new HashSet<>();
4949
Set<String> localeKeys = new HashSet<>();
50-
50+
5151
for (String key : cachedKeys) {
5252
if (HunspellService.isPackageCacheKey(key)) {
5353
packageKeys.add(key);
5454
} else {
5555
localeKeys.add(key);
5656
}
5757
}
58-
58+
5959
HunspellCacheInfoResponse response = new HunspellCacheInfoResponse(
6060
cachedKeys.size(),
6161
packageKeys.size(),
6262
localeKeys.size(),
6363
packageKeys,
6464
localeKeys
6565
);
66-
66+
6767
listener.onResponse(response);
6868
} catch (Exception e) {
6969
listener.onFailure(e);
7070
}
7171
}
72-
}
72+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*/
8+
9+
/** Hunspell cache management transport handlers for invalidation and info operations. */
10+
package org.opensearch.action.admin.indices.cache.hunspell;

0 commit comments

Comments
 (0)