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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ public class HunspellTokenFilterFactory extends AbstractTokenFilterFactory {
private final Dictionary dictionary;
private final boolean dedup;
private final boolean longestOnly;
private final AnalysisMode analysisMode;
private final HunspellService hunspellService;
private final String refPath;
private final String locale;

public HunspellTokenFilterFactory(IndexSettings indexSettings, String name, Settings settings, HunspellService hunspellService) {
super(indexSettings, name, settings);
Expand All @@ -82,6 +86,10 @@ public HunspellTokenFilterFactory(IndexSettings indexSettings, String name, Sett
String refPath = settings.get("ref_path");
String locale = settings.get("locale", settings.get("language", settings.get("lang", null)));

// Check for updateable flag
boolean updateable = settings.getAsBoolean("updateable", false);
this.analysisMode = updateable ? AnalysisMode.SEARCH_TIME : AnalysisMode.ALL;

if (refPath != null) {
// Directory-based loading: ref_path + locale (required)
if (locale == null) {
Expand All @@ -106,6 +114,10 @@ public HunspellTokenFilterFactory(IndexSettings indexSettings, String name, Sett
);
}

this.hunspellService = hunspellService;
this.refPath = refPath;
this.locale = locale;

dedup = settings.getAsBoolean("dedup", true);
longestOnly = settings.getAsBoolean("longest_only", false);
}
Expand All @@ -123,6 +135,29 @@ public boolean longestOnly() {
return 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;
}
Comment thread
shayush622 marked this conversation as resolved.

/**
* Reloads this filter's hunspell dictionary from disk, atomically replacing the cached entry.
* The cache is never empty — the old dictionary is overwritten in place.
*/
Comment thread
shayush622 marked this conversation as resolved.
@Override
public void reloadCachedResources() {
if (refPath != null) {
hunspellService.reloadDictionaryFromRefPath(refPath, locale);
} else if (locale != null) {
hunspellService.reloadDictionary(locale);
}
}
Comment thread
shayush622 marked this conversation as resolved.

/**
* Allowlist pattern for a ref_path.
* Permits alphanumeric characters, hyphens, underscores, and forward slashes as path separators.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,11 @@ public TokenStream create(TokenStream tokenStream) {
return tokenStream;
}
};

/**
* Reloads any cached resources held by this filter factory from their source.
* Called during analyzer reload when cache refresh is requested.
* Default implementation is a no-op.
*/
default void reloadCachedResources() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -846,16 +846,36 @@ protected Analyzer getWrappedAnalyzer(String fieldName) {
}

public synchronized List<String> reloadSearchAnalyzers(AnalysisRegistry registry) throws IOException {
logger.info("reloading search analyzers");
// refresh indexAnalyzers and search analyzers
return reloadSearchAnalyzers(registry, false);
}

/**
* Reloads search analyzers, optionally reloading cached resources (e.g. hunspell dictionaries) first.
*
* @param registry The analysis registry
* @param reloadCachedResources If true, reloads cached resources (e.g. hunspell dictionaries) before rebuilding analyzers
* @return List of reloaded analyzer names
*/
public synchronized List<String> reloadSearchAnalyzers(AnalysisRegistry registry, boolean reloadCachedResources) throws IOException {
logger.info("reloading search analyzers (reloadCachedResources={})", reloadCachedResources);

if (reloadCachedResources) {
// Reload cached resources BEFORE building new factories so they pick up fresh data
for (NamedAnalyzer namedAnalyzer : indexAnalyzers.getAnalyzers().values()) {
if (namedAnalyzer.analyzer() instanceof ReloadableCustomAnalyzer analyzer) {
Arrays.stream(analyzer.getComponents().getTokenFilters()).forEach(TokenFilterFactory::reloadCachedResources);
}
}
}

// Build new factories — they will load fresh dictionaries from disk
final Map<String, TokenizerFactory> tokenizerFactories = registry.buildTokenizerFactories(indexSettings);
final Map<String, CharFilterFactory> charFilterFactories = registry.buildCharFilterFactories(indexSettings);
final Map<String, TokenFilterFactory> tokenFilterFactories = registry.buildTokenFilterFactories(indexSettings);
final Map<String, Settings> settings = indexSettings.getSettings().getGroups("index.analysis.analyzer");
final List<String> reloadedAnalyzers = new ArrayList<>();
for (NamedAnalyzer namedAnalyzer : indexAnalyzers.getAnalyzers().values()) {
if (namedAnalyzer.analyzer() instanceof ReloadableCustomAnalyzer) {
ReloadableCustomAnalyzer analyzer = (ReloadableCustomAnalyzer) namedAnalyzer.analyzer();
if (namedAnalyzer.analyzer() instanceof ReloadableCustomAnalyzer analyzer) {
String analyzerName = namedAnalyzer.name();
Settings analyzerSettings = settings.get(analyzerName);
analyzer.reload(analyzerName, analyzerSettings, tokenizerFactories, charFilterFactories, tokenFilterFactories);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ private static Settings loadDictionarySettings(Path dir, Settings defaults) thro
return defaults;
}

// ==================== CACHE KEY UTILITIES ====================
// ==================== CACHE UTILITIES ====================

/**
* Builds the cache key for a directory-based dictionary.
Expand All @@ -389,4 +389,62 @@ public static String buildRefPathCacheKey(String refPath, String locale) {
return refPath + CACHE_KEY_SEPARATOR + locale;
}

/**
* Reloads a directory-based dictionary from disk and atomically replaces the cached entry.
* The cache key is never empty — the old entry is overwritten in a single put.
*
* @param refPath The ref_path (e.g., "analyzers/my-dict")
* @param locale The locale (e.g., "en_US")
* @return The freshly loaded Dictionary
* @throws IllegalStateException if reloading fails
*/
public Dictionary reloadDictionaryFromRefPath(String refPath, String locale) {
if (Strings.isNullOrEmpty(refPath)) {
throw new IllegalArgumentException("refPath cannot be null or empty");
}
if (Strings.isNullOrEmpty(locale)) {
throw new IllegalArgumentException("locale cannot be null or empty");
}

String cacheKey = buildRefPathCacheKey(refPath, locale);

final Dictionary freshDictionary;
try {
freshDictionary = loadDictionaryFromRefPath(refPath, locale);
} catch (Exception e) {
throw new IllegalStateException(
String.format(Locale.ROOT, "Failed to reload hunspell dictionary for ref_path [%s] locale [%s]", refPath, locale),
e
);
}

dictionaries.put(cacheKey, freshDictionary);
logger.debug("Reloaded hunspell dictionary cache for key [{}]", cacheKey);
return freshDictionary;
}

/**
* Reloads a traditional locale-based dictionary from disk and atomically replaces the cached entry.
*
* @param locale The locale (e.g., "en_US")
* @return The freshly loaded Dictionary
* @throws IllegalStateException if reloading fails
*/
public Dictionary reloadDictionary(String locale) {
if (Strings.isNullOrEmpty(locale)) {
throw new IllegalArgumentException("locale cannot be null or empty");
}

final Dictionary freshDictionary;
try {
freshDictionary = loadDictionary(locale, Settings.EMPTY, env, hunspellDir);
} catch (Exception e) {
throw new IllegalStateException(String.format(Locale.ROOT, "Failed to reload hunspell dictionary for locale [%s]", locale), e);
}

dictionaries.put(locale, freshDictionary);
logger.debug("Reloaded hunspell dictionary cache for locale [{}]", locale);
return freshDictionary;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,20 @@

package org.opensearch.index.analysis;

import org.apache.lucene.analysis.hunspell.Dictionary;
import org.opensearch.Version;
import org.opensearch.cluster.metadata.IndexMetadata;
import org.opensearch.common.settings.Settings;
import org.opensearch.env.Environment;
import org.opensearch.index.IndexSettings;
import org.opensearch.indices.analysis.HunspellService;
import org.opensearch.test.IndexSettingsModule;
import org.opensearch.test.OpenSearchTestCase;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;

import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.instanceOf;
Expand Down Expand Up @@ -368,4 +377,145 @@ public void testLanguageAliasForLocale() throws IOException {
assertThat(tokenFilter, instanceOf(HunspellTokenFilterFactory.class));
}

/**
* Test that updateable flag sets analysis mode to 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", "analyzers/test-dict")
.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;

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", "analyzers/test-dict")
.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.getAnalysisMode(), is(AnalysisMode.ALL));
}

/**
* Test that reloadCachedResources() reloads the ref_path dictionary.
*/
public void testReloadCachedResourcesRefPath() throws Exception {
Path tempDir = createTempDir();
Path dictDir = tempDir.resolve("config").resolve("analyzers/test-dict").resolve("hunspell").resolve("en_US");
Files.createDirectories(dictDir);
Files.write(dictDir.resolve("en_US.aff"), Collections.singletonList("SET UTF-8"));
Files.write(dictDir.resolve("en_US.dic"), java.util.Arrays.asList("1", "test"));

Settings nodeSettings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build();
Environment env = new Environment(nodeSettings, tempDir.resolve("config"));
HunspellService hunspellService = new HunspellService(nodeSettings, env, Collections.emptyMap());

IndexSettings idx = IndexSettingsModule.newIndexSettings(
"test",
Settings.builder().put(IndexMetadata.SETTING_INDEX_VERSION_CREATED.getKey(), Version.CURRENT).build()
);
Settings filterSettings = Settings.builder().put("ref_path", "analyzers/test-dict").put("locale", "en_US").build();

HunspellTokenFilterFactory factory = new HunspellTokenFilterFactory(idx, "my_hunspell", filterSettings, hunspellService);

Dictionary dict1 = hunspellService.getDictionaryFromRefPath("analyzers/test-dict", "en_US");
assertNotNull(dict1);

factory.reloadCachedResources();
Dictionary dict2 = hunspellService.getDictionaryFromRefPath("analyzers/test-dict", "en_US");
assertNotNull(dict2);
assertNotSame("Expected fresh Dictionary instance after reloadCachedResources()", dict1, dict2);
}

/**
* Test that reloadCachedResources() reloads the traditional locale dictionary.
*/
public void testReloadCachedResourcesTraditionalLocale() throws Exception {
Path tempDir = createTempDir();
Path dir = tempDir.resolve("config").resolve("hunspell").resolve("en_US");
Files.createDirectories(dir);
Files.write(dir.resolve("en_US.aff"), Collections.singletonList("SET UTF-8"));
Files.write(dir.resolve("en_US.dic"), java.util.Arrays.asList("1", "test"));

Settings nodeSettings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build();
Environment env = new Environment(nodeSettings, tempDir.resolve("config"));
HunspellService hunspellService = new HunspellService(nodeSettings, env, Collections.emptyMap());

IndexSettings idx = IndexSettingsModule.newIndexSettings(
"test",
Settings.builder().put(IndexMetadata.SETTING_INDEX_VERSION_CREATED.getKey(), Version.CURRENT).build()
);
Settings filterSettings = Settings.builder().put("locale", "en_US").build();

HunspellTokenFilterFactory factory = new HunspellTokenFilterFactory(idx, "my_hunspell", filterSettings, hunspellService);

Dictionary dict1 = hunspellService.getDictionary("en_US");
assertNotNull(dict1);

factory.reloadCachedResources();
Dictionary dict2 = hunspellService.getDictionary("en_US");
assertNotNull(dict2);
assertNotSame("Expected fresh Dictionary instance after reloadCachedResources()", dict1, dict2);
}

/**
* Simulates the MapperService.reloadSearchAnalyzers(registry, true) cacheReload walk.
* Verifies that iterating token filters and calling reloadCachedResources() on HunspellTokenFilterFactory
* correctly evicts the cached dictionary.
*/
Comment thread
shayush622 marked this conversation as resolved.
public void testMapperServiceReloadWalk() throws Exception {
Path tempDir = createTempDir();
Path dictDir = tempDir.resolve("config").resolve("analyzers/test-dict").resolve("hunspell").resolve("en_US");
Files.createDirectories(dictDir);
Files.write(dictDir.resolve("en_US.aff"), Collections.singletonList("SET UTF-8"));
Files.write(dictDir.resolve("en_US.dic"), java.util.Arrays.asList("1", "test"));

Settings nodeSettings = Settings.builder().put(Environment.PATH_HOME_SETTING.getKey(), tempDir).build();
Environment env = new Environment(nodeSettings, tempDir.resolve("config"));
HunspellService hunspellService = new HunspellService(nodeSettings, env, Collections.emptyMap());

IndexSettings idx = IndexSettingsModule.newIndexSettings(
"test",
Settings.builder().put(IndexMetadata.SETTING_INDEX_VERSION_CREATED.getKey(), Version.CURRENT).build()
);
Settings filterSettings = Settings.builder()
.put("ref_path", "analyzers/test-dict")
.put("locale", "en_US")
.put("updateable", true)
.build();

HunspellTokenFilterFactory hunspellFactory = new HunspellTokenFilterFactory(idx, "my_hunspell", filterSettings, hunspellService);

// Pre-load into cache
Dictionary dict1 = hunspellService.getDictionaryFromRefPath("analyzers/test-dict", "en_US");
assertNotNull(dict1);

// Reload cached resources
hunspellFactory.reloadCachedResources();

// Verify eviction
Dictionary dict2 = hunspellService.getDictionaryFromRefPath("analyzers/test-dict", "en_US");
assertNotSame("Dictionary should be fresh after reload walk", dict1, dict2);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,41 @@ public void testReloadSearchAnalyzers() throws IOException {
);
}

public void testReloadSearchAnalyzersWithReloadCachedResources() throws IOException {
Settings settings = Settings.builder()
.put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1)
.put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 1)
.put("index.analysis.analyzer.reloadableAnalyzer.type", "custom")
.put("index.analysis.analyzer.reloadableAnalyzer.tokenizer", "standard")
.putList("index.analysis.analyzer.reloadableAnalyzer.filter", "myReloadableFilter")
.build();

MapperService mapperService = createIndex("test_index", settings).mapperService();
CompressedXContent mapping = new CompressedXContent(
BytesReference.bytes(
XContentFactory.jsonBuilder()
.startObject()
.startObject("_doc")
.startObject("properties")
.startObject("field")
.field("type", "text")
.field("analyzer", "simple")
.field("search_analyzer", "reloadableAnalyzer")
.endObject()
.endObject()
.endObject()
.endObject()
)
);

mapperService.merge("_doc", mapping, MergeReason.MAPPING_UPDATE);

// Call with reloadCachedResources=true — exercises the reload loop and no-op default
List<String> reloaded = mapperService.reloadSearchAnalyzers(getInstanceFromNode(AnalysisRegistry.class), true);
assertEquals(1, reloaded.size());
assertEquals("reloadableAnalyzer", reloaded.get(0));
}

public void testMapperDynamicAllowedIgnored() {
final List<Function<Settings.Builder, Settings.Builder>> scenarios = List.of(
(builder) -> builder.putNull(MapperService.INDEX_MAPPER_DYNAMIC_SETTING.getKey()),
Expand Down
Loading
Loading