**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 *
成功返回新快照(version 记录装载前读到的词典版本号,供后续版本比对);
161 * 装载失败时若已有旧快照则续用旧值(保持可服务),没有旧快照则返回 null(不落缓存,
162 * 由 {@link #readySnapshot} 的退避窗口控制重试节奏)。
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 * 调用方必须在动作开始前取定句柄并贯穿全程,绝不在循环里反复取新句柄——
184 * 一个动作(一轮分词 / 一次下发解析)只能用一张表,中途换表会产出"前半旧版、后半新版"的混合结果。
185 *
186 * @return 数据源不合法,或装载失败且处于退避窗口内时返回 {@code null};
187 * 调用方必须据此跳过本次动作,绝不能用空表去扫——那会把短语整块漏切成单词,并被剪枝永久固化。
188 */
189 public PhraseIndexSnapshot readySnapshot(DataBaseTypeEnum source) {
190 if (!isSourceValid(source)) {
191 return null;
192 }
193 LoadingCache 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 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 * 读时解析划词 ID 专用:下发的 {@code text} 就是本索引里的 {@code normalizedText}(两者同源,
260 * 命中时直接复用索引文本),所以等值比较就能还原到当初的命中项。刻意复用同一份快照与
261 * 同一候选序:一个文本同时挂在多个短语下时,回填选到的词条与分词当时选到的保持一致。
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 * 方法名保持"重建"语义以兼容既有调用方与测试断言,实际动作是失效 + 惰性重载,
285 * 省掉写路径上的同步扫库——发布/保存事务不再携带装载耗时。
286 *
287 * 必须在数据提交之后调用:版本号先于数据可见地自增,会让其它实例重装到旧数据
288 * 却记下新版本号,从而永久漏掉这次变更(直到下一次变更)。现有三个调用点
289 * (短语编辑、发布、导入完成)都在事务提交后,新增调用方须守住这一点。
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 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 * 专供“结果会落库固化”的入口在动作开始前调用:LABS 同步、发布后单题重算、
328 * 手动/每日全量刷。这些入口是题目级频率,一次额外 GET(版本变了则加一次装载)
329 * 换“绝不用旧词典落库”,很划算;而 C 端下发是请求级,仍走节流窗口——
330 * 它索引旧只是暂时点不开,不产生错误数据。
331 *
332 * 与“把节流时间戳归零”相比,这里直接执行比对:归零后可能被并发线程的
333 * CAS 当场占领窗口,使“强制”静默失效。
334 */
335 public void forceVersionCheck(DataBaseTypeEnum source) {
336 if (!isSourceValid(source)) {
337 return;
338 }
339 LoadingCache 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 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 * 失败只记日志:版本号是“让其它进程更早换表”的提前量,不是本机失效的前提;
425 * 更不能因为 Redis 抖动让保存/发布回滚。失败时其它实例退回 TTL 收敛。
426 */
427 private void bumpVersion(DataBaseTypeEnum source, String reason) {
428 if (redissonHelper == null) {
429 return;
430 }
431 try {
432 Optional 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> entries = new HashMap<>();
476 long lastId = 0L;
477 while (true) {
478 List 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 phraseIds = phrases.stream().map(EnglishPhraseDo::getId)
490 .collect(Collectors.toCollection(LinkedHashSet::new));
491 // 页大小可配,IN 必须再分批:否则一页 500 条就会把 500 个 ID 塞进单条 SQL。
492 int inBatchSize = resolveVariantInBatchSize();
493 Map> 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> immutableEntries =
511 new HashMap<>();
512 entries.forEach((key, value) -> {
513 List 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> 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.)