**Exploration: forceVersionCheck refreshIfVersionChanged EnglishPhraseIndexCacheService**

Found 29 symbols across 1 file.

**Blast radius — what depends on these (update/verify before editing)**

- `forceVersionCheck` (application/src/main/java/cn/jojo/cc/biz/cache/english/EnglishPhraseIndexCacheService.java:335) — 4 callers in `api/admin-worker/src/main/java/cn/jojo/cc/worker/event/listener/SegmentPublishRecalcConsumerListener.java`, `api/admin-worker/src/main/java/cn/jojo/cc/worker/event/listener/SegmentSyncComputeConsumerListener.java`, `application/src/main/java/cn/jojo/cc/biz/service/english/segment/SegmentRefreshBizServiceImpl.java`; tests: `application/src/test/java/cn/jojo/cc/biz/cache/english/EnglishPhraseIndexCacheServiceTest.java`
- `refreshIfVersionChanged` (application/src/main/java/cn/jojo/cc/biz/cache/english/EnglishPhraseIndexCacheService.java:351) — 2 callers in `application/src/main/java/cn/jojo/cc/biz/cache/english/EnglishPhraseIndexCacheService.java`; ⚠️ no covering tests found
- `version` (application/src/main/java/cn/jojo/cc/biz/model/bo/english/PhraseIndexSnapshot.java:38) — 6 callers in `application/src/main/java/cn/jojo/cc/biz/cache/english/EnglishPhraseIndexCacheService.java`, `application/src/main/java/cn/jojo/cc/biz/service/english/segment/SegmentMatchIdResolver.java`; tests: `application/src/test/java/cn/jojo/cc/biz/cache/english/EnglishPhraseIndexCacheServiceTest.java`
- `check` (application/src/main/java/cn/jojo/cc/biz/service/impl/BEndDataBizServiceImpl.java:313) — 1 caller in `application/src/main/java/cn/jojo/cc/biz/service/impl/BEndDataBizServiceImpl.java`; ⚠️ no covering tests found

**Relationships**

**calls:**
- forceVersionCheck → isSourceValid
- forceVersionCheck → refreshIfVersionChanged
- doConcreteConsume → forceVersionCheck
- doConcreteConsume → forceVersionCheck
- refreshSource → forceVersionCheck
- forceVersionCheckBypassesThrottleWindow → forceVersionCheck
- refreshIfVersionChanged → isVersionCheckEnabled
- refreshIfVersionChanged → getIfPresent
- refreshIfVersionChanged → version
- refreshIfVersionChanged → nextVersionCheckAtRef
- refreshIfVersionChanged → resolveCheckMillis
- refreshIfVersionChanged → readVersionQuietly
- refreshIfVersionChanged → nextRetryAtRef
- refreshIfVersionChanged → invalidate
- refreshIfVersionChanged → get
- ... and 169 more

**references:**
- forceVersionCheck → DataBaseTypeEnum
- refreshIfVersionChanged → UNKNOWN_VERSION
- refreshIfVersionChanged → DataBaseTypeEnum
- refreshIfVersionChanged → PhraseIndexSnapshot
- loadSnapshot → PhraseIndexSnapshot
- readySnapshot → PhraseIndexSnapshot
- findLongestAt → PhraseIndexSnapshot
- findMatchByExactText → PhraseIndexSnapshot
- buildSnapshot → PhraseIndexSnapshot
- addEntry → PhraseIndexSnapshot
- empty → PhraseIndexSnapshot
- rescanAsPlanned → PhraseIndexSnapshot
- notifyRescanOnceIfNeeded → PhraseIndexSnapshot
- check → LessonCreateDetailBo
- LessonInfoImportExportProcessor → checkLessonKey
- ... and 69 more

**imports:**
- cn.jojo.cc.biz.cache.english → PhraseIndexSnapshot
- cn.jojo.cc.biz.service.english.segment → PhraseIndexSnapshot
- cn.jojo.cc.biz.service.english.segment → PhraseIndexSnapshot
- cn.jojo.cc.biz.cache.english → PhraseIndexSnapshot
- cn.jojo.cc.biz.service.english.segment → PhraseIndexSnapshot
- cn.jojo.cc.biz.service.english.segment → PhraseIndexSnapshot

**instantiates:**
- buildSnapshot → PhraseIndexSnapshot
- empty → PhraseIndexSnapshot
- check → LessonDo
- check → LessonCreateDetailBo

**implements:**
- BEndDataBizServiceImpl → BEndDataBizService

**decorates:**
- SegmentRefreshJobHandler → Component

**Source Code**

> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.

**`application/src/main/java/cn/jojo/cc/biz/cache/english/EnglishPhraseIndexCacheService.java`** — calls(calls), DataBaseTypeEnum(references), PhraseIndexSnapshot(references), nextRetryAtRef(calls), version(calls), isSourceValid(calls), get(calls), references(references), error(calls), refreshIfVersionChanged(calls), getIfPresent(calls), resolveLoadRetryMillis(calls), entries(calls), instantiates(instantiates), invalidate(calls), +42 more

```java
139	     * TTL 在构建时定死，Apollo 热调对本类不生效（进程重启才换新值）；
140	     * 本地缓存只驻两个 key、装载是毫秒级单页扫描，热调没有真实诉求，不为此加动态重建机器。
141	     */
142	    @PostConstruct
143	    public void init() {
144	        long ttlMinutes = resolveTtlMinutes();
145	        cache = Caffeine.newBuilder()
146	            .maximumSize(DataBaseTypeEnum.values().length)
147	            .refreshAfterWrite(ttlMinutes, TimeUnit.MINUTES)
148	            .build(this::loadSnapshot);
149	        log.info("英文短语索引缓存初始化完成, ttlMinutes={}", ttlMinutes);
150	    }
151	
152	    private long resolveTtlMinutes() {
153	        int configured = englishDictConfig.getCacheLocalTtlMinutes();
154	        return configured > 0 ? configured : FALLBACK_TTL_MINUTES;
155	    }
156	
157	    /**
158	     * LoadingCache 装载入口：永远不向外抛异常。
159	     *
160	     * <p>成功返回新快照（version 记录装载前读到的词典版本号，供后续版本比对）；
161	     * 装载失败时若已有旧快照则续用旧值（保持可服务），没有旧快照则返回 null（不落缓存，
162	     * 由 {@link #readySnapshot} 的退避窗口控制重试节奏）。</p>
163	     */
164	    private PhraseIndexSnapshot loadSnapshot(DataBaseTypeEnum source) {
165	        try {
166	            // 顺序不可颠倒：先读版本、后扫库。反过来的话会把“旧数据”配上“新版本号”，
167	            // 该实例会认为自己是新的、永久漏掉这次变更（只能等下一次变更或 TTL）。
168	            long version = readVersionOrUnknown(source);
169	            PhraseIndexSnapshot snapshot = buildSnapshot(source, version);
170	            lastKnownSnapshots.put(source, snapshot);
171	            log.info("英文短语索引装载完成, source={}, version={}", source, version);
172	            return snapshot;
173	        } catch (Exception e) {
174	            log.error("英文短语索引装载失败, source={}, 尝试续用上一次成功快照", source, e);
175	            return lastKnownSnapshots.get(source);
176	        }
177	    }
178	
179	    /**
180	     * 取本次动作要用的短语索引句柄：先做一次带节流的跨实例版本比对（发现词典变了就丢弃本机
181	     * 副本重装），本地命中时再按 TTL 取当前值（超 TTL 时安排异步刷新）。
182	     *
183	     * <p>调用方必须在动作开始前取定句柄并贯穿全程，绝不在循环里反复取新句柄——
184	     * 一个动作（一轮分词 / 一次下发解析）只能用一张表，中途换表会产出"前半旧版、后半新版"的混合结果。</p>
185	     *
186	     * @return 数据源不合法，或装载失败且处于退避窗口内时返回 {@code null}；
187	     * 调用方必须据此跳过本次动作，绝不能用空表去扫——那会把短语整块漏切成单词，并被剪枝永久固化。
188	     */
189	    public PhraseIndexSnapshot readySnapshot(DataBaseTypeEnum source) {
190	        if (!isSourceValid(source)) {
191	            return null;
192	        }
193	        LoadingCache<DataBaseTypeEnum, PhraseIndexSnapshot> currentCache = cache;
194	        if (currentCache == null) {
195	            // 理论上 @PostConstruct 已建好；容器早期注入序异常时按未就绪降级，绝不 NPE 冒泡阻断业务。
196	            log.warn("英文短语索引缓存尚未初始化, source={}", source);
197	            return null;
198	        }
199	        // 先花一个节流窗口的成本确认“手上的副本是不是过期了”，再决定要不要走装载路径。
200	        // 放在 getIfPresent 之前：否则本地命中会直接返回旧副本，比对永远没机会执行。
201	        refreshIfVersionChanged(source, currentCache, false);
202	
203	        PhraseIndexSnapshot existing = currentCache.getIfPresent(source);
204	        if (existing != null) {
205	            // get 在超 TTL 时会安排异步重载并立即返回当前值，请求线程不等待装载。
206	            return currentCache.get(source);
207	        }
208	        long now = System.currentTimeMillis();
209	        AtomicLong nextRetryAt = nextRetryAtRef(source);
210	        if (now < nextRetryAt.get()) {
211	            // 装载失败退避窗口内：不重复打库，本次按未就绪处理。
212	            return null;
213	        }
214	        PhraseIndexSnapshot loaded = currentCache.get(source);
215	        if (loaded == null) {
216	            long retryMillis = resolveLoadRetryMillis();
217	            nextRetryAt.set(now + retryMillis);
218	            log.warn("英文短语索引装载失败，进入重试退避窗口, source={}, retryAfterMillis={}",
219	                source, retryMillis);
220	        } else {
221	            nextRetryAt.set(0L);
222	        }
223	        return loaded;
224	    }
225	
226	    /**
227	     * 在指定快照上从 cursor 起点查找最长短语；未命中或句柄为 {@code null} 返回 null。
228	     */
229	    public PhraseMatch findLongestAt(PhraseIndexSnapshot snapshot, String normalizedText, int cursor) {
230	        if (snapshot == null || normalizedText == null || cursor < 0 || cursor >= normalizedText.length()) {
231	            return null;
232	        }
233	        if (!isAsciiLetter(normalizedText.charAt(cursor))) {
234	            return null;
235	        }
236	        List<PhraseIndexSnapshot.PhraseIndexEntry> candidates =
237	            snapshot.entries(normalizedText.charAt(cursor));
238	        for (PhraseIndexSnapshot.PhraseIndexEntry candidate : candidates) {
239	            int end = cursor + candidate.normalizedText().length();
240	            if (end > normalizedText.length()
241	                || !normalizedText.regionMatches(cursor, candidate.normalizedText(), 0,
242	                    candidate.normalizedText().length())) {
243	                continue;
244	            }
245	            if (EnglishWords.isPartOfWord(normalizedText, cursor - 1)
246	                || EnglishWords.isPartOfWord(normalizedText, end)) {
247	                continue;
248	            }
249	            // 命中游标与 end 均基于归一化文本，下发同样用归一化小写，保持与单词分支、C 端匹配口径一致。
250	            return new PhraseMatch(candidate.phraseId(), candidate.variantId(),
251	                candidate.normalizedText(), end);
252	        }
253	        return null;
254	    }
255	
256	    /**
257	     * 按整串归一化文本精确查短语/变形项；查不到返回 null。
258	     *
259	     * <p>读时解析划词 ID 专用：下发的 {@code text} 就是本索引里的 {@code normalizedText}（两者同源，
260	     * 命中时直接复用索引文本），所以等值比较就能还原到当初的命中项。刻意复用同一份快照与
261	     * 同一候选序：一个文本同时挂在多个短语下时，回填选到的词条与分词当时选到的保持一致。</p>
262	     */
263	    public PhraseMatch findMatchByExactText(PhraseIndexSnapshot snapshot, String text) {
264	        if (snapshot == null || StringUtils.isBlank(text)) {
265	            return null;
266	        }
267	        String normalized = TextNormalizer.normalize(text);
268	        if (StringUtils.isBlank(normalized) || !isAsciiLetter(normalized.charAt(0))) {
269	            return null;
270	        }
271	        for (PhraseIndexSnapshot.PhraseIndexEntry candidate : snapshot.entries(normalized.charAt(0))) {
272	            if (normalized.equals(candidate.normalizedText())) {
273	                return new PhraseMatch(candidate.phraseId(), candidate.variantId(),
274	                    candidate.normalizedText(), normalized.length());
275	            }
276	        }
277	        return null;
278	    }
279	
280	    /**
281	     * 词典变更驱动的立即失效：本进程下次读取即回源新快照，同时自增远端版本号，
282	     * 其它进程在下一个比对窗口内自行换表（不依赖 MQ 广播——分词所在的 worker 收不到清本地缓存广播）。
283	     *
284	     * <p>方法名保持"重建"语义以兼容既有调用方与测试断言，实际动作是失效 + 惰性重载，
285	     * 省掉写路径上的同步扫库——发布/保存事务不再携带装载耗时。</p>
286	     *
287	     * <p><b>必须在数据提交之后调用</b>：版本号先于数据可见地自增，会让其它实例重装到旧数据
288	     * 却记下新版本号，从而永久漏掉这次变更（直到下一次变更）。现有三个调用点
289	     * （短语编辑、发布、导入完成）都在事务提交后，新增调用方须守住这一点。</p>
290	     */
291	    public void rebuildNow(DataBaseTypeEnum source, String reason) {
292	        if (!isSourceValid(source)) {
293	            return;
294	        }
295	        // 先自增版本号（失败只记日志，绝不能影响本机失效，更不能阻断保存/发布主流程）：
296	        // 其它实例据此在下一个比对窗口换表。本机自己靠下面的 invalidate 立即生效。
297	        bumpVersion(source, reason);
298	        LoadingCache<DataBaseTypeEnum, PhraseIndexSnapshot> currentCache = cache;
299	        if (currentCache != null) {
300	            currentCache.invalidate(source);
301	        }
302	        // 同步清掉失败续旧登记：主动变更后若再装载失败，宁可交 null 让调用方跳过，
303	        // 也不能把失效前的旧词典续回来。
304	        lastKnownSnapshots.remove(source);
305	        // 主动变更后允许立即重载，撤销此前可能存在的失败退避窗口。
306	        nextRetryAtRef(source).set(0L);
307	        log.info("英文短语索引已失效，下次读取时重载, source={}, reason={}", source, reason);
308	    }
309	
310	    /**
311	     * 精确失效短语索引。
312	     */
313	    public void invalidatePhrase(DataBaseTypeEnum source, Long phraseId) {
314	        rebuildNow(source, "phrase invalidate: " + phraseId);
315	    }
316	
317	    /**
318	     * 精确失效短语变形。
319	     */
320	    public void invalidateVariant(DataBaseTypeEnum source, Long phraseId, Long variantId) {
321	        rebuildNow(source, "variant invalidate: " + phraseId + "/" + variantId);
322	    }
323	
324	    /**
325	     * 立即做一次版本比对（绕过节流窗口），必要时当场重装快照。
326	     *
327	     * <p>专供“结果会落库固化”的入口在动作开始前调用：LABS 同步、发布后单题重算、
328	     * 手动/每日全量刷。这些入口是题目级频率，一次额外 GET（版本变了则加一次装载）
329	     * 换“绝不用旧词典落库”，很划算；而 C 端下发是请求级，仍走节流窗口——
330	     * 它索引旧只是暂时点不开，不产生错误数据。</p>
331	     *
332	     * <p>与“把节流时间戳归零”相比，这里直接执行比对：归零后可能被并发线程的
333	     * CAS 当场占领窗口，使“强制”静默失效。</p>
334	     */
335	    public void forceVersionCheck(DataBaseTypeEnum source) {
336	        if (!isSourceValid(source)) {
337	            return;
338	        }
339	        LoadingCache<DataBaseTypeEnum, PhraseIndexSnapshot> currentCache = cache;
340	        if (currentCache == null) {
341	            return;
342	        }
343	        refreshIfVersionChanged(source, currentCache, true);
344	    }
345	
346	    /**
347	     * 版本比对：本机副本比远端旧就丢弃重装；窗口内每实例最多查一次版本号。
348	     *
349	     * @param force {@code false} 走节流窗口；{@code true} 不计窗口、立即比对（仅供落库类入口）
350	     */
351	    private void refreshIfVersionChanged(DataBaseTypeEnum source,
352	                                        LoadingCache<DataBaseTypeEnum, PhraseIndexSnapshot> currentCache,
353	                                        boolean force) {
354	        if (!isVersionCheckEnabled()) {
355	            return;
356	        }
357	        PhraseIndexSnapshot local = currentCache.getIfPresent(source);
358	        if (local == null || local.version() == UNKNOWN_VERSION) {
359	            // 没有本机副本（首次装载中）或上次装载没读到版本：比对无意义，交给装载/刷新路径。
360	            return;
361	        }
362	        long now = System.currentTimeMillis();
363	        AtomicLong nextCheckAt = nextVersionCheckAtRef(source);
364	        long nextCheck = nextCheckAt.get();
365	        if (!force) {
366	            if (now < nextCheck) {
367	                return;
368	            }
369	            if (!nextCheckAt.compareAndSet(nextCheck, now + resolveCheckMillis())) {
370	                // 别的线程刚抢到本次比对，本线程直接复用当前副本。
371	                return;
372	            }
373	        } else {
374	            // 强制比对：顺手把窗口推进，避开紧接着的读取再打一次 Redis。
375	            nextCheckAt.set(now + resolveCheckMillis());
376	        }
377	        Long remote = readVersionQuietly(source);
378	        if (remote == null || remote == local.version()) {
379	            return;
380	        }
381	        if (now < nextRetryAtRef(source).get()) {
382	            // 上一轮重装刚失败过：退避窗口内继续用旧副本，不重复扫库。
383	            log.warn("短语索引版本已变化但处于重装退避窗口，继续沿用旧副本, source={}, local={}, remote={}",
384	                source, local.version(), remote);
385	            return;
386	        }
387	        currentCache.invalidate(source);
388	        PhraseIndexSnapshot reloaded = currentCache.get(source);
389	        if (reloaded == null || reloaded.version() != remote) {
390	            // 重装失败，或装载期间版本又变了：进退避，本轮不再试。
391	            nextRetryAtRef(source).set(System.currentTimeMillis() + resolveLoadRetryMillis());
392	            log.warn("短语索引按新版本重装未成功，进入退避窗口, source={}, remote={}", source, remote);
393	            return;
394	        }
395	        nextRetryAtRef(source).set(0L);
396	        log.info("短语索引已按新版本重装, source={}, oldVersion={}, newVersion={}",
397	            source, local.version(), remote);
398	    }
399	
400	    private long readVersionOrUnknown(DataBaseTypeEnum source) {
401	        Long version = readVersionQuietly(source);
402	        return version == null ? UNKNOWN_VERSION : version;
403	    }
404	
405	    /**
406	     * 读远端版本号；任何异常都吞掉返回 null——版本号只用于“能不能更早换表”，
407	     * 读不到最多退回 TTL 收敛，绝不能让 Redis 抖动阻断分词或下发。
408	     */
409	    private Long readVersionQuietly(DataBaseTypeEnum source) {
410	        if (redissonHelper == null) {
411	            return null;
412	        }
413	        try {
414	            return redissonHelper.getAtomicLongValue(buildVersionKey(source));
415	        } catch (Exception e) {
416	            log.warn("读取短语索引版本号失败，本次跳过版本比对, source={}", source, e);
417	            return null;
418	        }
419	    }
420	
421	    /**
422	     * 写词典后自增版本号。
423	     *
424	     * <p>失败只记日志：版本号是“让其它进程更早换表”的提前量，不是本机失效的前提；
425	     * 更不能因为 Redis 抖动让保存/发布回滚。失败时其它实例退回 TTL 收敛。</p>
426	     */
427	    private void bumpVersion(DataBaseTypeEnum source, String reason) {
428	        if (redissonHelper == null) {
429	            return;
430	        }
431	        try {
432	            Optional<Long> version = redissonHelper.incrAndGet(buildVersionKey(source));
433	            log.info("短语索引版本号已自增, source={}, version={}, reason={}",
434	                source, version == null ? null : version.orElse(null), reason);
435	        } catch (Exception e) {
436	            log.error("短语索引版本号自增失败，其它进程本次靠 TTL 收敛, source={}, reason={}",
437	                source, reason, e);
438	        }
439	    }
440	
441	    private String buildVersionKey(DataBaseTypeEnum source) {
442	        return RedisKeyEnum.ENGLISH_PHRASE_INDEX_VERSION.buildKey(
443	            source == DataBaseTypeEnum.PROD ? "prod" : "default");
444	    }
445	
446	    private long resolveCheckMillis() {
447	        // 配置占位符已给默认 5000；这里只兜“被误配成 0 或负数”的情况，至少保留 1ms 窗口。
448	        return Math.max(1, englishDictConfig.getPhraseIndexVersionCheckMillis());
449	    }
450	
451	    private int resolvePageSize() {
452	        return Math.max(1, englishDictConfig.getPhraseIndexPageSize());
453	    }
454	
455	    private long resolveLoadRetryMillis() {
456	        return Math.max(1, englishDictConfig.getPhraseIndexLoadRetryMillis());
457	    }
458	
459	    private int resolveVariantInBatchSize() {
460	        return Math.max(1, variantInBatchSize);
461	    }
462	
463	    private boolean isVersionCheckEnabled() {
464	        return englishDictConfig.isPhraseIndexVersionCheckEnabled();
465	    }
466	
467	    private AtomicLong nextVersionCheckAtRef(DataBaseTypeEnum source) {
468	        return source == DataBaseTypeEnum.PROD
469	            ? prodNextVersionCheckAtMillis : teachingNextVersionCheckAtMillis;
470	    }
471	
472	    private PhraseIndexSnapshot buildSnapshot(DataBaseTypeEnum source, long version) {
473	        int pageSize = resolvePageSize();
474	        return executeInSource(source, () -> {
475	            Map<Character, List<PhraseIndexSnapshot.PhraseIndexEntry>> entries = new HashMap<>();
476	            long lastId = 0L;
477	            while (true) {
478	                List<EnglishPhraseDo> phrases = englishPhraseCoreService.listPageByIdGt(lastId, pageSize);
479	                if (CollectionUtils.isEmpty(phrases)) {
480	                    break;
481	                }
482	                long pageLastId = phrases.get(phrases.size() - 1).getId();
483	                if (source == DataBaseTypeEnum.PROD) {
484	                    phrases = phrases.stream()
485	                        .filter(item -> EnglishPublishStatusEnum.PUBLISHED.getCode().equals(item.getStatus()))
486	                        .toList();
487	                }
488	                if (!phrases.isEmpty()) {
489	                    Set<Long> phraseIds = phrases.stream().map(EnglishPhraseDo::getId)
490	                        .collect(Collectors.toCollection(LinkedHashSet::new));
491	                    // 页大小可配，IN 必须再分批：否则一页 500 条就会把 500 个 ID 塞进单条 SQL。
492	                    int inBatchSize = resolveVariantInBatchSize();
493	                    Map<Long, List<EnglishPhraseVariantDo>> variants =
494	                        Lists.partition(new ArrayList<>(phraseIds), inBatchSize)
495	                            .stream()
496	                            .flatMap(batch -> englishPhraseVariantCoreService.listByPhraseIds(batch, 0L).stream())
497	                            .collect(Collectors.groupingBy(EnglishPhraseVariantDo::getPhraseId));
498	                    for (EnglishPhraseDo phrase : phrases) {
499	                        addEntry(entries, phrase.getPhrase(), phrase.getPhrase(),
500	                            phrase.getId(), null);
501	                        for (EnglishPhraseVariantDo variant :
502	                            variants.getOrDefault(phrase.getId(), Collections.emptyList())) {
503	                            addEntry(entries, variant.getVariantText(), variant.getVariantText(),
504	                                phrase.getId(), variant.getId());
505	                        }
506	                    }
507	                }
508	                lastId = pageLastId;
509	            }
510	            Map<Character, List<PhraseIndexSnapshot.PhraseIndexEntry>> immutableEntries =
511	                new HashMap<>();
512	            entries.forEach((key, value) -> {
513	                List<PhraseIndexSnapshot.PhraseIndexEntry> sorted = new ArrayList<>(value);
514	                sorted.sort(Comparator
515	                    .comparingInt((PhraseIndexSnapshot.PhraseIndexEntry item) ->
516	                        item.normalizedText().length()).reversed()
517	                    .thenComparing(PhraseIndexSnapshot.PhraseIndexEntry::phraseId)
518	                    .thenComparing(PhraseIndexSnapshot.PhraseIndexEntry::variantId,
519	                        Comparator.nullsFirst(Long::compareTo)));
520	                immutableEntries.put(key, Collections.unmodifiableList(sorted));
521	            });
522	            return new PhraseIndexSnapshot(version, immutableEntries);
523	        });
524	    }
525	
526	    private void addEntry(Map<Character, List<PhraseIndexSnapshot.PhraseIndexEntry>> entries,
527	                          String text, String displayText, Long phraseId, Long variantId) {
528	        String normalized = TextNormalizer.normalize(text);
529	        if (StringUtils.isBlank(normalized)
530	            || !EnglishWords.isAsciiLetter(normalized.charAt(0))) {
531	            return;
532	        }
533	        PhraseIndexSnapshot.PhraseIndexEntry entry =
534	            new PhraseIndexSnapshot.PhraseIndexEntry(normalized, displayText, phraseId, variantId);
535	        entries.computeIfAbsent(normalized.charAt(0), key -> new ArrayList<>()).add(entry);
536	    }
537	
538	    private AtomicLong nextRetryAtRef(DataBaseTypeEnum source) {
539	        return source == DataBaseTypeEnum.PROD
540	            ? prodNextLoadRetryAtMillis : teachingNextLoadRetryAtMillis;
541	    }
542	
543	    private boolean isSourceValid(DataBaseTypeEnum source) {
544	        return source != null && source != DataBaseTypeEnum.BOTH;
545	    }
546	
547	    private boolean isAsciiLetter(char c) {
548	        return EnglishWords.isAsciiLetter(c);
```


... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another codegraph_explore with the specific names — do NOT Read these files.)
