**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.)