feat(flexlb): support Nacos local standby hot updates - #1358
Conversation
LLLLKKKK
left a comment
There was a problem hiding this comment.
AI Code Review - PR #1358
Status: BLOCKING
Summary: P0/0 · P1/1 · P2/14 · P3/8
Reviewed: commit dd4571eaa7e5 · 2026-08-31 16:13 UTC+8
Blocking Issues
P1
- 关闭 KVCM 的 Nacos 推送会持续闭锁该进程的全部配置热更新,含日志等级 @
rtp_llm/flexlb/flexlb-common/src/main/java/org/flexlb/config/LocalStandbyRuntimeSettings.java:24- 建议:不要让「运行时无法生效」的字段拥有否决权:KVCM 缺失/被关闭时让投影退化为返回上一次成功值(等价 no-op,applier 不触发)并记一条 WARN 说明「关闭 KVCM 需重启生效」,仅在五个热更字段取值越界时才否决整次推送;把
kvcm前置检查限制在启动期ModelServiceConfiguration校验路径。补两条用例:运行时推送kvcm.enabled=false后,同批次及后续推送中的flexlbLogLevel仍能生效,且该 disable 意图能落进快照供下次重启使用。若确实要保留否决语义,必须在06-configuration-and-observability.md明确「运行时不可关闭 kvcm,此类推送会拒绝整份配置」,提供替代 kill switch,并补覆盖该路径的单测。
- 建议:不要让「运行时无法生效」的字段拥有否决权:KVCM 缺失/被关闭时让投影退化为返回上一次成功值(等价 no-op,applier 不触发)并记一条 WARN 说明「关闭 KVCM 需重启生效」,仅在五个热更字段取值越界时才否决整次推送;把
Non-blocking Suggestions
P2
- HBM 估算不可用时运行时下调的容量配置被静默丢弃 @
rtp_llm/flexlb/flexlb-cache/src/main/java/org/flexlb/cache/match/localstandby/LocalStandbyCacheManager.java:209- 建议:在
updateRuntimeSettings中先按新 settings 无条件夹紧一次索引上限(估算不可用时直接updateMaximumEntries(settings.maximumEntries())),再让 60s 周期任务收敛到基于 HBM 的估算值;estimate <= 0分支至少打一条 warn 说明跳过原因。补一个单测:workerStatusProvider返回空集合时运行时下调maximum_entries,断言maximumEntryCount()等于新配置值。
- 建议:在
- 容量刷新可被定时线程与配置通知线程并发执行,新上限可能被旧配置覆盖 @
rtp_llm/flexlb/flexlb-cache/src/main/java/org/flexlb/cache/match/localstandby/LocalStandbyCacheManager.java:213- 建议:把
refreshCapacityLimits()声明为synchronized,或用专用 lock 包住「读 settings → 估算 → 写上限」临界区,也可统一投递到单线程 executor 串行执行,保证 :213 读到的 settings 与 :216 写入的上限来自同一版本配置。补一条定时刷新与配置推送并发不回退的用例。
- 建议:把
- 运行期投影校验范围小于启动期校验,非热更字段的非法值成为重启地雷 @
rtp_llm/flexlb/flexlb-common/src/main/java/org/flexlb/config/ModelServiceConfiguration.java:81- 建议:在
ConfigService再注册一个覆盖整个modelServiceConfig的「校验专用」投影(applier 为空实现),复用与validateServiceRoute/validateLocalStandby等价的规则,使「运行期能接受」与「重启能启动」保持一致;并补一条「推送非热更字段的非法值会被拒绝并保留 last-known-good」的单测。对合法但无法运行时生效的字段变化,至少log.warn明确提示「已接受但需重启生效」。
- 建议:在
- 递归合并同时改变了启动期 env+Nacos 的嵌套解析语义,文档与测试只覆盖运行期 @
rtp_llm/flexlb/flexlb-common/src/main/java/org/flexlb/config/ConfigService.java:154- 建议:在
06-configuration-and-observability.md启动期优先级第 3 条同样写明「嵌套对象逐字段递归合并、数组整体替换、显式 null 覆盖」;并在ConfigServiceTest增加启动期用例:env 提供含非默认kvcm的MODEL_SERVICE_CONFIG、Nacos 提供省略kvcm的modelServiceConfig,断言最终kvcm取值,把该语义固化。
- 建议:在
- 递归合并对数组与 JSON null 的整体替换语义缺少回归测试 @
rtp_llm/flexlb/flexlb-common/src/test/java/org/flexlb/service/config/NacosConfigSourceTest.java:133- 建议:增加用例:初始配置含多个
role_endpoints,推送只含单元素role_endpoints的modelServiceConfig,断言合并后列表长度为 1;再推送{"modelServiceConfig":{"kvcm":null}}断言该推送被拒并保留 last-known-good,固化「非对象字段整体替换」的契约。
- 建议:增加用例:初始配置含多个
- 新的唯一校验权威 LocalStandbyRuntimeSettings 缺少聚焦单测 @
rtp_llm/flexlb/flexlb-common/src/main/java/org/flexlb/config/LocalStandbyRuntimeSettings.java:40- 建议:新增
LocalStandbyRuntimeSettingsTest,用参数化用例覆盖 5 个字段各自的边界(0、负值、NaN/Infinity、比值取 0 与 1、minimumTtlMs == ttlMs、capacityMultiplier == 1.0),断言from((LocalStandbyConfig) null)返回与LocalStandbyConfig.DEFAULT_*一致的默认值,并覆盖from(FlexlbConfig)两条拒绝分支及其消息。
- 建议:新增
- NacosConfigSourceTest 缺少 @AfterEach,断言失败会把静态配置源泄漏给同 JVM 后续用例 @
rtp_llm/flexlb/flexlb-common/src/test/java/org/flexlb/service/config/NacosConfigSourceTest.java:162- 建议:在
NacosConfigSourceTest增加与ConfigServiceTest一致的@AfterEach:用字段保存创建出的ConfigService并在 teardown 中无条件close()(或用 try-finally 包裹断言),使静态CONFIG_SOURCES在失败路径上也被清理。两个测试类对同一静态资源应使用同一套清理范式。
- 建议:在
- 仅测试使用的构造重载迫使生产代码保留不可达 null 分支,并可静默关闭热更新 @
rtp_llm/flexlb/flexlb-cache/src/main/java/org/flexlb/cache/match/localstandby/LocalStandbyCacheManager.java:49- 建议:移除这两个重载与随之而来的 null 判空,测试统一改用完整构造并传入
mock(ConfigService.class)(LocalStandbyCacheManagerTest:166-175已经这么写,Mockito 默认对addUpdateListener不做任何事,与当前 null 路径等价)。若确需可选依赖,改用ObjectProvider<ConfigService>并在跳过订阅时log.info说明原因,让「热更新未启用」成为可观测状态。
- 建议:移除这两个重载与随之而来的 null 判空,测试统一改用完整构造并传入
- manager 到 cacheIndex 的 TTL 透传链路缺少断言,两个同类型 long 参数写反不会被发现 @
rtp_llm/flexlb/flexlb-cache/src/test/java/org/flexlb/cache/match/localstandby/LocalStandbyCacheManagerTest.java:156- 建议:在该用例后补一个断言块:先
addRoutedRequestBlocks写入映射,再通过订阅回调推入ttl_ms/minimum_ttl_ms极小的运行时配置,然后断言findMatchingEngines不再命中且mappingCount()归零,使 TTL 透传与传参顺序具备可失败的覆盖。
- 建议:在该用例后补一个断言块:先
- DefaultRouter 的 null 兜底与下游策略的非空前提矛盾,且回滚路径改动无可失败覆盖 @
rtp_llm/flexlb/flexlb-sync/src/main/java/org/flexlb/balance/scheduler/DefaultRouter.java:140- 建议:明确不变量并统一实现:既然
RouteService.route()保证setConfig先于router.route(),可在DefaultRouter入口一次取值并Objects.requireNonNull快速失败,把getLoadBalancer简化为只接收FlexlbConfig;若保留兜底则收敛为BalanceContext上的单一访问器供各调用方共用。同时补一条用例:stubgetConfig()返回策略与configService.loadBalanceConfig()不同的快照,令第二个角色选路失败,verify回滚发生在该快照对应的 LoadBalancer 上且loadBalanceConfig()从未被调用。
- 建议:明确不变量并统一实现:既然
- RouteService.cancel 的配置来源变更零测试覆盖 @
rtp_llm/flexlb/flexlb-sync/src/main/java/org/flexlb/service/RouteService.java:59- 建议:新增最小
RouteServiceTest,覆盖三种情形:getConfig()返回enableQueueing=true的快照(断言cancel()与 future 异常完成)、返回enableQueueing=false的快照(断言不取消)、返回 null(断言回退到configService.loadBalanceConfig())。
- 建议:新增最小
- 预测缓存过期窗口复用索引 ttl_ms,热更新下调会静默削减对比观测 @
rtp_llm/flexlb/flexlb-cache/src/main/java/org/flexlb/cache/match/localstandby/LocalStandbyComparisonService.java:137- 建议:为预测关联窗口使用独立配置字段,或至少给一个下界(如
max(ttlMs, 最小反馈等待时长));并在因过期导致对比缺失时通过CacheMetricsReporter上报计数指标,使这种降级可观测。
- 建议:为预测关联窗口使用独立配置字段,或至少给一个下界(如
- 推送被拒与 applier 失败均不可定位,且失败时仍打印 Applied 成功日志 @
rtp_llm/flexlb/flexlb-common/src/main/java/org/flexlb/config/ConfigService.java:113- 建议:
from(LocalStandbyConfig)的异常消息带上首个非法字段名与实际值(如minimum_ttl_ms=200 > ttl_ms=100);ModelServiceConfiguration:91改为new IllegalArgumentException(msg, e)保留 cause(若ModelServiceConfigurationTest依赖hasRootCauseMessage需同步调整);拒绝推送的 error 日志带上异常对象以保留 stack;notifyListeners统计失败数并返回,存在失败时不再输出 Applied 成功 info;给订阅增加名字维度(如addUpdateListener(name, projection, applier))并在失败日志中输出;通过现有 metrics 上报「推送被拒次数」与「applier 失败次数」。
- 建议:
- 订阅注册期的首次回放未受保护,与更新期错误语义不一致 @
rtp_llm/flexlb/flexlb-common/src/main/java/org/flexlb/config/ConfigService.java:74- 建议:明确注册期契约并写入 javadoc:若选择 fail-fast,则保证投影异常信息足以定位字段,并在文档中说明「注册期投影失败 = 启动失败」;若不希望如此,则把首次回放失败按运行期处理(记录并保留订阅,等下次推送重试)。补一条「注册期 projection 抛异常」的单测固化该行为。
P3
- 运行时缩短 TTL 后未主动触发清理,索引占用收敛延迟 @
rtp_llm/flexlb/flexlb-cache/src/main/java/org/flexlb/cache/match/localstandby/LocalStandbyCacheIndex.java:130- 建议:与
updateMaximumEntries保持对称:在updateExpirationSettings末尾同样调用requestHighWatermarkCleanupIfNeeded(),或在检测到 TTL 相比旧值缩短时把checksSinceLastCleanup直接推到阈值,使下一次后台检查即执行批量清理。
- 建议:与
- applier 部分失败后回退到上次成功值的推送会被静默跳过 @
rtp_llm/flexlb/flexlb-common/src/main/java/org/flexlb/config/ConfigService.java:222- 建议:为每个订阅记录「上次通知失败」标记,失败后下一次推送无论投影值是否变化都重新投递;或在
addUpdateListenerjavadoc 中明确要求 applier 必须原子(先全部校验、再一次性写入),并说明违反该约定的后果。
- 建议:为每个订阅记录「上次通知失败」标记,失败后下一次推送无论投影值是否变化都重新投递;或在
- 构造函数中注册订阅造成 this-escape 并在 bean 构造期发起 worker 扫描 @
rtp_llm/flexlb/flexlb-cache/src/main/java/org/flexlb/cache/match/localstandby/LocalStandbyCacheManager.java:81- 建议:把订阅注册从构造函数移到
@PostConstruct(或SmartInitializingSingleton),让构造函数只做字段赋值,既消除 this-escape,也避免在 bean 构造期查询尚未同步完成的 worker 状态。
- 建议:把订阅注册从构造函数移到
- disabled 分支重复手写五个默认值常量 @
rtp_llm/flexlb/flexlb-cache/src/main/java/org/flexlb/cache/match/localstandby/LocalStandbyCacheManager.java:67- 建议:disabled 分支直接调用
LocalStandbyRuntimeSettings.from((LocalStandbyConfig) null),让默认值只有一个权威来源。
- 建议:disabled 分支直接调用
- 比较缓存 TTL 用例依赖挂钟余量,且负向断言可因错误原因通过 @
rtp_llm/flexlb/flexlb-cache/src/test/java/org/flexlb/cache/match/localstandby/LocalStandbyComparisonServiceTest.java:81- 建议:把延长方向的 TTL 提高到数十秒量级(如 30_000)以消除绝对时间预算;在「过期」用例缩短 TTL 之前先断言一次预测可被取回,使负向断言只能因过期而成立。若要彻底去掉墙钟依赖,可让生产代码支持注入 Caffeine
Ticker,测试中用假时钟显式推进时间。
- 建议:把延长方向的 TTL 提高到数十秒量级(如 30_000)以消除绝对时间预算;在「过期」用例缩短 TTL 之前先断言一次预测可被取回,使负向断言只能因过期而成立。若要彻底去掉墙钟依赖,可让生产代码支持注入 Caffeine
- publishesOnlyChangedValidatedRuntimeSettings 中存在无效赋值 @
rtp_llm/flexlb/flexlb-common/src/test/java/org/flexlb/service/config/ConfigServiceTest.java:208- 建议:在 :211 之后补一条
assertThat(service.loadBalanceConfig()).isNotSameAs(lastKnownGood);,让 :208 的赋值产生实际断言价值,明确区分「快照已替换」与「投影未变化、不重复通知」两个语义;若不打算补断言,则删除 :208 的无效赋值以免误导读者。
- 建议:在 :211 之后补一条
- 新增的 @SuppressWarnings("unchecked") 冗余且与同 PR 内等价写法不一致 @
rtp_llm/flexlb/flexlb-sync/src/test/java/org/flexlb/service/monitor/FlexlbLogManagerTest.java:41- 建议:删除该注解以保持同 PR 内一致;若某个编译配置确实报警,应在最小作用域(局部变量声明)上压制并注明原因,而不是整个方法。
- addUpdateListener 中的两段 updateLock 对正确性无额外作用 @
rtp_llm/flexlb/flexlb-common/src/main/java/org/flexlb/config/ConfigService.java:71- 建议:保留
notificationLock的串行化语义,把addUpdateListener中的updateLock收敛为只包裹updateListeners的注册(读取currentConfig直接走AtomicReference),并在注释中明确updateLock的保护范围仅为「快照替换 + 订阅列表」以及与构造期initializeConfigSources的互斥。
- 建议:保留
Checklist Findings (12 fail / 26 total)
General Principles Checklist
- [6.1] Architecture — 兼容性:外部 HTTP/RPC API、持久数据、配置、环境迁移安全 → issue
递归合并同时改变了启动期 env+Nacos 的嵌套解析语义,文档与测试只覆盖运行期
mergeObjectFields被mergeConfig复用,而mergeConfig同时服务启动路径initializeConfigSources(:90)与运行期receiveConfigUpdate(:107)。EnvironmentConfigSource:40序列化完整FlexlbConfig作为基线,因此启动时 Nacos 的modelServiceConfig从「整对象替换 env 值」变为「逐字段深合并」:若 Nacos DataId 有意省略kvcm以关闭 KVCM,而 envMODEL_SERVICE_CONFIG中kvcm.enabled=true,该字段现在会被继承,cache-match 模式在下次重启时反转。文档 :21-22 只在运行期写了「递归覆盖」,启动期第 3 条(:18-20)未更新;letsNacosOverrideEnvironmentModelServiceConfig(:142-155)只断言service_id,无法区分两种语义。 - [6.1] Architecture — 分层边界:新概念在正确层级,不泄漏内部 → issue
关闭 KVCM 的 Nacos 推送会持续闭锁该进程的全部配置热更新,含日志等级
from(FlexlbConfig)在kvcm == null || !kvcm.isEnabled()时抛IllegalArgumentException(:24-26)。该投影在 KVCM 启用时必然注册(CacheMatchConfiguration:36令localStandbyEnabled == kvcmEnabled;LocalStandbyCacheManager:81、LocalStandbyComparisonService:55)。任一投影抛异常即跳过currentConfig.set(ConfigService:107-111),整次推送被拒。NacosConfigSource:128-134每次投递完整 DataId,故kvcm.enabled=false一旦写入 DataId,后续每次推送都被同样拒绝:FlexlbLogManager:34-35注册的flexlbLogLevel/enableStdoutLog,以及enableQueueing、负载均衡策略等热更字段被长期 - [6.1] Architecture — 可观测性:日志/指标/超时可操作、非噪声 → issue
推送被拒与 applier 失败均不可定位,且失败时仍打印 Applied 成功日志
三处叠加:(1) 五个字段的 9 个校验谓词共用固定文案 "local standby runtime settings are invalid"(LocalStandbyRuntimeSettings:49),不含字段名与实际值;ModelServiceConfiguration:89-94重新包装时未传 cause,原始信息被完全丢弃;receiveConfigUpdate的 catch 只打e.getMessage()(:116-119),无 cause 与 stack。(2)notifyListeners捕获异常后仅log.error("...but a runtime listener failed")(:129),不含订阅名,多订阅共存时无法判断是谁失败;随后 :114 仍无条件打印Applied FlexLB configuration update,基于关键字的告警易误判。(3) 没有「推送被拒次数」「applier 失败次数」指标,而 applier 失败后快照已提交、运行态仍是旧值,这种不一致完全不可监控。 - [6.1] Architecture — 回滚路径:风险行为存在运维回滚手段 → issue
推送被拒与 applier 失败均不可定位,且失败时仍打印 Applied 成功日志
三处叠加:(1) 五个字段的 9 个校验谓词共用固定文案 "local standby runtime settings are invalid"(LocalStandbyRuntimeSettings:49),不含字段名与实际值;ModelServiceConfiguration:89-94重新包装时未传 cause,原始信息被完全丢弃;receiveConfigUpdate的 catch 只打e.getMessage()(:116-119),无 cause 与 stack。(2)notifyListeners捕获异常后仅log.error("...but a runtime listener failed")(:129),不含订阅名,多订阅共存时无法判断是谁失败;随后 :114 仍无条件打印Applied FlexLB configuration update,基于关键字的告警易误判。(3) 没有「推送被拒次数」「applier 失败次数」指标,而 applier 失败后快照已提交、运行态仍是旧值,这种不一致完全不可监控。 - [6.1] Architecture — 状态不变量:创建/更新/失败/重试/回滚路径有效 → issue
构造函数中注册订阅造成 this-escape 并在 bean 构造期发起 worker 扫描
addUpdateListener会立即回放(ConfigService:74),即在构造函数内同步调用this::updateRuntimeSettings,进而读cacheIndex、serviceRoutes、workerStatusProvider并触发一次全量 worker 容量扫描(refreshCapacityLimits)。当前只因为注册恰好是构造函数最后一条语句才安全;任何字段赋值顺序调整都会退化为 NPE。LocalStandbyComparisonService:55是同一模式。 - [6.1] Architecture — 错误语义:fail-fast/retry/fallback/silent 行为显式 → issue
applier 部分失败后回退到上次成功值的推送会被静默跳过
prepareUpdate在Objects.equals(currentValue, updatedValue)时返回空 Runnable(:222-224),而currentValue只在listener.accept成功后推进(:225-228)。若某个 applier 在写入部分运行时状态之后抛异常,currentValue仍停留在旧值 U 而真实运行时状态已变成 V;随后运维把配置回滚为 U 时投影值等于currentValue,applier 不会被调用,运行时状态永久停在 V。类 javadoc(:194-200)与文档 :82-83 只承诺「下一次仍不同于上次成功值的推送会重试」,未覆盖这条回滚路径。当前两个 applier 恰好都接近原子,因此暂无可达触发点,属框架级隐性契约缺口。 - [6.1] Software Engineering — DRY:重复非平凡逻辑被抽取或显式复用 → issue
disabled 分支重复手写五个默认值常量
LocalStandbyRuntimeSettings.from(LocalStandbyConfig)在入参为 null 时已用new LocalStandbyConfig()取默认值(:34),而LocalStandbyConfig的字段初值正是DEFAULT_TTL_MS等 5 个常量,与 disabled 分支手写的 5 个DEFAULT_*(:67-72)完全等价。同一份默认值现在有两处来源,将来任一DEFAULT_*变更或新增字段时容易漏改其中一处。 - [6.1] Software Engineering — KISS/YAGNI:无投机性抽象 → issue
addUpdateListener 中的两段 updateLock 对正确性无额外作用
addUpdateListener全程持有notificationLock,而updateListeners与currentConfig在运行期的唯一其他访问者receiveConfigUpdate同样以notificationLock为最外层锁,initializeConfigSources只在构造期执行(对象未发布,不可能与addUpdateListener并发),且currentConfig本身是AtomicReference。因此 :71 与 :75 两段synchronized (updateLock)虽然无害,但不改变任何可见性或互斥结论,只增加读者判断「两把锁各自保护什么」的成本。 - [6.1] Software Engineering — SRP:模块/类职责单一 → issue
预测缓存过期窗口复用索引 ttl_ms,热更新下调会静默削减对比观测
updateRuntimeSettings直接用settings.ttlMs()调setExpiresAfter(:138-140)。但ttl_ms的语义是「块映射保鲜期」,而pendingLocalStandbyPredictions的窗口语义是「等待引擎反馈的最长时间」,两者只是历史上共用同一字段(构造期 :52 已如此)。改成热更新后,运维为控制索引容量把ttl_ms调小会同时缩短反馈关联窗口:尚未收到CacheHitFeedback的预测被提前淘汰,buildCacheHitComparison走prediction == null分支(:94-96)返回localStandby=null,cache_hit_comparison 观测静默缺失,既无日志也无指标可区分「未预测」与「预测已过期」。 - [6.1] Tests — 新逻辑有聚焦单测 + 相关集成/smoke 测试 → issue
publishesOnlyChangedValidatedRuntimeSettings 中存在无效赋值
:208 的FlexlbConfig lastKnownGood = service.loadBalanceConfig();在 :212 被重新赋值前从未被读取,是一次死赋值;中间只有source.emit("{\"enableQueueing\":true}")(:210)与assertThat(updates).containsExactly(9)(:211)。从命名看作者原本想断言「仅 enableQueueing 变化时快照被替换、但投影值未变因而不触发 applier」,实际只断言了后半句,读者容易误以为「投影未变」等于「快照未变」。 - [6.1] Tests — 边界 case 覆盖(空、单元素、最大值) → issue
比较缓存 TTL 用例依赖挂钟余量,且负向断言可因错误原因通过
retainsPendingPredictionUsingExtendedRuntimeTtl在 :81 把 TTL 更新为 1_000ms,随后put与buildCacheHitComparison(...).get(1, SECONDS)之间夹入Thread.sleep(20)(:83),整条链路绝对预算只有 1s,CI 上一次 full GC 或调度抖动就可能让条目提前过期而误报失败。另一侧expiresPendingPredictionUsingUpdatedRuntimeTtl的assertNull(...localStandby())(:61)在「预测根本没写入缓存」时同样会通过,缺少写入成功的前置断言。同分片LocalStandbyCacheIndexTest:37-38已示范注入时间戳的确定性写法。
RTP-LLM Checklist
- [I] 代码质量 — 同一功能用统一工具函数 → issue
新增的 @SuppressWarnings("unchecked") 冗余且与同 PR 内等价写法不一致
appliesConfigUpdates新增方法级@SuppressWarnings("unchecked")(:41),但其内部只是把invocation.getArgument(0)赋给Function<FlexlbConfig, Object>(:52-53);getArgument声明为<T> T getArgument(int),由目标类型推断,调用点不产生 unchecked 警告。本 PR 中LocalStandbyCacheManagerTest:167-173与LocalStandbyComparisonServiceTest:46-51、70-75使用完全相同的doAnswer模式却均未加该注解,同一 PR 内出现两种写法。
Strengths
- 提交时序正确且修正了旧语义错误:
ConfigService.java:106-112在updateLock内先对所有订阅求值投影再currentConfig.set,任一投影抛异常即整次推送被拒并保留 last-known-good;改动前存在「快照已 set 却记录 keeping last-known-good」的矛盾语义,本次一并修好。 prepareUpdate是纯函数(只算投影并返回 Runnable,不改状态),使「先校验全部投影、再提交快照」这一不变量在部分求值时也成立,且用户 applier 不在快照锁内执行。- 失败重试语义精确:
currentValue只在 applier 成功后推进(ConfigService.java:225-228),ConfigServiceTest.continuesNotifyingOtherRuntimeSettingsWhenOneApplierFails用attempted=[9,10,10,11]/delivered=[9,10,11]两条独立序列把该语义完全钉死。 - 并发不变量有确定性护栏:两个门闩用例分别锁定「注册与推送竞态不丢更新」与「先提交快照(:330 断言
maxRetryCount==10)+ 回调串行(maximumConcurrentCallbacks==1)+ 顺序 9/10/11」,所有等待都有 1s 上界;两处await(100ms)).isFalse()是安全方向断言,不会因 CI 负载升高误报。 - TTL 热更新的并发发布方式正确:三字段收敛为不可变
ExpirationSettingsrecord 并以单个 volatile 引用整体替换(LocalStandbyCacheIndex.java:33、131、306),effectiveTtlNanos()先快照到局部变量再多次读取(:189-205),彻底消除逐字段 volatile 的跨字段撕裂读。 - 递归合并修复了一个真实缺陷:此前
merged.setAll(overrides)会让仅含kvcm.local_standby的推送整体替换modelServiceConfig、丢掉service_id/role_endpoints;NacosConfigSourceTest:195-201精准断言非法推送后仅推maximum_entries时其余四字段仍继承(500, 10.0, 300_000, 100_000, 0.8),覆盖了本 PR 风险最高的改动。 DefaultRouter.routeByRoleType(:117)与rollBackRoutingFailure(:161)共享同一份请求级快照,消除了「选点用新策略、回滚用旧策略」导致回滚到另一个 LoadBalancer、泄漏本地增量状态的隐患。- 校验权威收敛到
LocalStandbyRuntimeSettings.from:容量/TTL 五字段的规则从ModelServiceConfiguration内联条件移出,避免启动期与运行期规则漂移,对外错误信息保持不变。 LocalStandbyCacheIndexTest.appliesUpdatedTtlToExistingMappings(:31-41)用注入的查询时间点System.nanoTime() + 2ms而非 sleep 断言过期,并同时验证mappingCount回收,是本 PR 最确定的时间相关用例,值得作为该模块范式。- 锁契约有文档:两把锁职责、
notificationLock → updateLock获取顺序、「用户 applier 不在快照锁内执行」、以及可运行时生效字段与必须重启字段的逐字段清单都写入类注释(ConfigService.java:30-40)与架构文档(:78-84、:56-59)。
| throw new IllegalArgumentException("modelServiceConfig must not be null"); | ||
| } | ||
| ServiceRoute serviceRoute = config.getModelServiceConfig(); | ||
| if (serviceRoute.getKvcm() == null || !serviceRoute.getKvcm().isEnabled()) { |
There was a problem hiding this comment.
[P1] 关闭 KVCM 的 Nacos 推送会持续闭锁该进程的全部配置热更新,含日志等级
from(FlexlbConfig) 在 kvcm == null || !kvcm.isEnabled() 时抛 IllegalArgumentException(:24-26)。该投影在 KVCM 启用时必然注册(CacheMatchConfiguration:36 令 localStandbyEnabled == kvcmEnabled;LocalStandbyCacheManager:81、LocalStandbyComparisonService:55)。任一投影抛异常即跳过 currentConfig.set(ConfigService:107-111),整次推送被拒。NacosConfigSource:128-134 每次投递完整 DataId,故 kvcm.enabled=false 一旦写入 DataId,后续每次推送都被同样拒绝:FlexlbLogManager:34-35 注册的 flexlbLogLevel/enableStdoutLog,以及 enableQueueing、负载均衡策略等热更字段被长期
建议: 不要让「运行时无法生效」的字段拥有否决权:KVCM 缺失/被关闭时让投影退化为返回上一次成功值(等价 no-op,applier 不触发)并记一条 WARN 说明「关闭 KVCM 需重启生效」,仅在五个热更字段取值越界时才否决整次推送;把 kvcm 前置检查限制在启动期 ModelServiceConfiguration 校验路径。补两条用例:运行时推送 kvcm.enabled=false 后,同批次及后续推送中的 flexlbLogLevel 仍能生效,且该 disable 意图能落进快照供下次重启使用。若确实要保留否决语义,必须在 06-configuration-and-observability.md 明确「运行时不可关闭 kvcm,此类推送会拒绝整份配置」,提供替代 kill switch,并补覆盖该路径的单测。
Checklist: [6.1] 分层边界:新概念在正确层级,不泄漏内部
| @@ -204,7 +210,8 @@ void refreshCapacityLimits() { | |||
| return; | |||
There was a problem hiding this comment.
📍 实际位置 rtp_llm/flexlb/flexlb-cache/src/main/java/org/flexlb/cache/match/localstandby/LocalStandbyCacheManager.java:209(不在 diff 展示范围内,就近挂载)
[P2] HBM 估算不可用时运行时下调的容量配置被静默丢弃
updateRuntimeSettings(:263-268)应用 maximum_entries/capacity_multiplier 的唯一手段是 refreshCapacityLimits(),而后者在 estimatedHbmBlockCapacity <= 0 时直接 return(:209-211),既不调用 cacheIndex.updateMaximumEntries 也不打印任何日志。calculateWorkerBlockCapacity(:279-291)在 worker 未存活、cacheStatus 为空或 totalKvCache/blockSize <= 0 时返回 0,即启动初期或集群状态同步中断时必然发生。此时索引沿用旧上限、mappingCount 可继续增长到旧上限,运维侧无任何线索说明本次配置未落地,与文档 :56-59「运行时生效」的表述有落差。
建议: 在 updateRuntimeSettings 中先按新 settings 无条件夹紧一次索引上限(估算不可用时直接 updateMaximumEntries(settings.maximumEntries())),再让 60s 周期任务收敛到基于 HBM 的估算值;estimate <= 0 分支至少打一条 warn 说明跳过原因。补一个单测:workerStatusProvider 返回空集合时运行时下调 maximum_entries,断言 maximumEntryCount() 等于新配置值。
| } | ||
|
|
||
| long newMaximumEntries = calculateMaximumEntries(estimatedHbmBlockCapacity); | ||
| LocalStandbyRuntimeSettings settings = runtimeSettings; |
There was a problem hiding this comment.
[P2] 容量刷新可被定时线程与配置通知线程并发执行,新上限可能被旧配置覆盖
本 PR 使 refreshCapacityLimits() 同时成为 @Scheduled(fixedDelay = 60_000) 定时任务(:201)与配置通知线程的同步调用点(:267),而方法内「读 runtimeSettings(:213)→ 估算 → 写 updateMaximumEntries(:216)」这段临界区无任何互斥。交错场景:定时线程在 :213 读到旧 settings,通知线程随后写入新 settings 并完成 updateMaximumEntries(new),定时线程再执行 :216 把上限写回旧值。后果是刚生效的运行时上限被回退,最长需等下一个 60s 周期才自愈;:218 的 "from X to Y" 日志也会打印出误导性配对。
建议: 把 refreshCapacityLimits() 声明为 synchronized,或用专用 lock 包住「读 settings → 估算 → 写上限」临界区,也可统一投递到单线程 executor 串行执行,保证 :213 读到的 settings 与 :216 写入的上限来自同一版本配置。补一条定时刷新与配置推送并发不回退的用例。
| @@ -80,21 +80,18 @@ private void validateLocalStandby(LocalStandbyConfig localStandby) { | |||
| } | |||
| if (localStandby.getBlockSize() < 0 | |||
There was a problem hiding this comment.
[P2] 运行期投影校验范围小于启动期校验,非热更字段的非法值成为重启地雷
validateLocalStandby 仍在启动期校验 block_size、async_queue_capacity、hash_thread_count、hash_queue_capacity(:81-88),而运行期只做 Jackson 反序列化 + 投影校验(ConfigService:107),投影 LocalStandbyRuntimeSettings 只含五个热更字段。推送 {"modelServiceConfig":{"kvcm":{"local_standby":{"hash_thread_count":0}}}} 会通过投影、提交进快照并打出 Applied FlexLB configuration update,运行期无任何拒绝或告警;但 Nacos DataId 已持久化该内容,下次重启时 :86 抛 IllegalArgumentException 使 modelMetaConfig bean 创建失败,形成重启即启动失败。本 PR 正鼓励运维直接编辑 kvcm.local_standby,命中概率被放大。
建议: 在 ConfigService 再注册一个覆盖整个 modelServiceConfig 的「校验专用」投影(applier 为空实现),复用与 validateServiceRoute/validateLocalStandby 等价的规则,使「运行期能接受」与「重启能启动」保持一致;并补一条「推送非热更字段的非法值会被拒绝并保留 last-known-good」的单测。对合法但无法运行时生效的字段变化,至少 log.warn 明确提示「已接受但需重启生效」。
| return config; | ||
| } | ||
|
|
||
| private void mergeObjectFields(ObjectNode base, ObjectNode overrides) { |
There was a problem hiding this comment.
[P2] 递归合并同时改变了启动期 env+Nacos 的嵌套解析语义,文档与测试只覆盖运行期
mergeObjectFields 被 mergeConfig 复用,而 mergeConfig 同时服务启动路径 initializeConfigSources(:90)与运行期 receiveConfigUpdate(:107)。EnvironmentConfigSource:40 序列化完整 FlexlbConfig 作为基线,因此启动时 Nacos 的 modelServiceConfig 从「整对象替换 env 值」变为「逐字段深合并」:若 Nacos DataId 有意省略 kvcm 以关闭 KVCM,而 env MODEL_SERVICE_CONFIG 中 kvcm.enabled=true,该字段现在会被继承,cache-match 模式在下次重启时反转。文档 :21-22 只在运行期写了「递归覆盖」,启动期第 3 条(:18-20)未更新;letsNacosOverrideEnvironmentModelServiceConfig(:142-155)只断言 service_id,无法区分两种语义。
建议: 在 06-configuration-and-observability.md 启动期优先级第 3 条同样写明「嵌套对象逐字段递归合并、数组整体替换、显式 null 覆盖」;并在 ConfigServiceTest 增加启动期用例:env 提供含非默认 kvcm 的 MODEL_SERVICE_CONFIG、Nacos 提供省略 kvcm 的 modelServiceConfig,断言最终 kvcm 取值,把该语义固化。
Checklist: [6.1] 兼容性:外部 HTTP/RPC API、持久数据、配置、环境迁移安全
| } | ||
|
|
||
| @Test | ||
| void appliesNestedLocalStandbyCapacityAndTtlUpdateFromNacos() throws Exception { |
There was a problem hiding this comment.
[P2] 递归合并对数组与 JSON null 的整体替换语义缺少回归测试
mergeObjectFields 只在 existing 与 override 同为 ObjectNode 时下钻,其余情况在 ConfigService:162 整体替换。三个新增 Nacos 用例(:133、:166、:206)只覆盖「对象字段深合并」与「数值非法被拒」,没有任何断言说明数组字段(基线 localStandbyConfig() 中的 role_endpoints)与 JSON null 是整体替换而非合并。这是深合并最易出错的边界:一旦后续被重构为通用 deep-merge 并开始合并数组,运维缩容 role_endpoints 会得到含已下线 endpoint 的陈旧列表并直接造成错误路由,而当前测试集不会失败。
建议: 增加用例:初始配置含多个 role_endpoints,推送只含单元素 role_endpoints 的 modelServiceConfig,断言合并后列表长度为 1;再推送 {"modelServiceConfig":{"kvcm":null}} 断言该推送被拒并保留 last-known-good,固化「非对象字段整体替换」的契约。
| : LocalStandbyConfig.DEFAULT_CAPACITY_MULTIPLIER; | ||
| this.runtimeSettings = enabled | ||
| ? LocalStandbyRuntimeSettings.from(config) | ||
| : new LocalStandbyRuntimeSettings( |
There was a problem hiding this comment.
[P3] disabled 分支重复手写五个默认值常量
LocalStandbyRuntimeSettings.from(LocalStandbyConfig) 在入参为 null 时已用 new LocalStandbyConfig() 取默认值(:34),而 LocalStandbyConfig 的字段初值正是 DEFAULT_TTL_MS 等 5 个常量,与 disabled 分支手写的 5 个 DEFAULT_*(:67-72)完全等价。同一份默认值现在有两处来源,将来任一 DEFAULT_* 变更或新增字段时容易漏改其中一处。
建议: disabled 分支直接调用 LocalStandbyRuntimeSettings.from((LocalStandbyConfig) null),让默认值只有一个权威来源。
Checklist: [6.1] DRY:重复非平凡逻辑被抽取或显式复用
| CacheMatchQuery query = new CacheMatchQuery( | ||
| "request-1", List.of(11L), 2_192, List.of(101L), 4_096, RoleType.PREFILL, "default"); | ||
|
|
||
| listener.get().accept(projection.get().apply(flexlbConfig(1_000, 1_000))); |
There was a problem hiding this comment.
[P3] 比较缓存 TTL 用例依赖挂钟余量,且负向断言可因错误原因通过
retainsPendingPredictionUsingExtendedRuntimeTtl 在 :81 把 TTL 更新为 1_000ms,随后 put 与 buildCacheHitComparison(...).get(1, SECONDS) 之间夹入 Thread.sleep(20)(:83),整条链路绝对预算只有 1s,CI 上一次 full GC 或调度抖动就可能让条目提前过期而误报失败。另一侧 expiresPendingPredictionUsingUpdatedRuntimeTtl 的 assertNull(...localStandby())(:61)在「预测根本没写入缓存」时同样会通过,缺少写入成功的前置断言。同分片 LocalStandbyCacheIndexTest:37-38 已示范注入时间戳的确定性写法。
建议: 把延长方向的 TTL 提高到数十秒量级(如 30_000)以消除绝对时间预算;在「过期」用例缩短 TTL 之前先断言一次预测可被取回,使负向断言只能因过期而成立。若要彻底去掉墙钟依赖,可让生产代码支持注入 Caffeine Ticker,测试中用假时钟显式推进时间。
Checklist: [6.1] 边界 case 覆盖(空、单元素、最大值)
| } | ||
| return maxRetryCount; | ||
| }, updates::add); | ||
| FlexlbConfig lastKnownGood = service.loadBalanceConfig(); |
There was a problem hiding this comment.
[P3] publishesOnlyChangedValidatedRuntimeSettings 中存在无效赋值
:208 的 FlexlbConfig lastKnownGood = service.loadBalanceConfig(); 在 :212 被重新赋值前从未被读取,是一次死赋值;中间只有 source.emit("{\"enableQueueing\":true}")(:210)与 assertThat(updates).containsExactly(9)(:211)。从命名看作者原本想断言「仅 enableQueueing 变化时快照被替换、但投影值未变因而不触发 applier」,实际只断言了后半句,读者容易误以为「投影未变」等于「快照未变」。
建议: 在 :211 之后补一条 assertThat(service.loadBalanceConfig()).isNotSameAs(lastKnownGood);,让 :208 的赋值产生实际断言价值,明确区分「快照已替换」与「投影未变化、不重复通知」两个语义;若不打算补断言,则删除 :208 的无效赋值以免误导读者。
Checklist: [6.1] 新逻辑有聚焦单测 + 相关集成/smoke 测试
| } | ||
|
|
||
| @Test | ||
| @SuppressWarnings("unchecked") |
There was a problem hiding this comment.
[P3] 新增的 @SuppressWarnings("unchecked") 冗余且与同 PR 内等价写法不一致
appliesConfigUpdates 新增方法级 @SuppressWarnings("unchecked")(:41),但其内部只是把 invocation.getArgument(0) 赋给 Function<FlexlbConfig, Object>(:52-53);getArgument 声明为 <T> T getArgument(int),由目标类型推断,调用点不产生 unchecked 警告。本 PR 中 LocalStandbyCacheManagerTest:167-173 与 LocalStandbyComparisonServiceTest:46-51、70-75 使用完全相同的 doAnswer 模式却均未加该注解,同一 PR 内出现两种写法。
建议: 删除该注解以保持同 PR 内一致;若某个编译配置确实报警,应在最小作用域(局部变量声明)上压制并注明原因,而不是整个方法。
Checklist: [I] 同一功能用统一工具函数
|
|
||
| synchronized (notificationLock) { | ||
| T initialValue; | ||
| synchronized (updateLock) { |
There was a problem hiding this comment.
[P3] addUpdateListener 中的两段 updateLock 对正确性无额外作用
addUpdateListener 全程持有 notificationLock,而 updateListeners 与 currentConfig 在运行期的唯一其他访问者 receiveConfigUpdate 同样以 notificationLock 为最外层锁,initializeConfigSources 只在构造期执行(对象未发布,不可能与 addUpdateListener 并发),且 currentConfig 本身是 AtomicReference。因此 :71 与 :75 两段 synchronized (updateLock) 虽然无害,但不改变任何可见性或互斥结论,只增加读者判断「两把锁各自保护什么」的成本。
建议: 保留 notificationLock 的串行化语义,把 addUpdateListener 中的 updateLock 收敛为只包裹 updateListeners 的注册(读取 currentConfig 直接走 AtomicReference),并在注释中明确 updateLock 的保护范围仅为「快照替换 + 订阅列表」以及与构造期 initializeConfigSources 的互斥。
Checklist: [6.1] KISS/YAGNI:无投机性抽象
Background
FlexLB currently reads configuration from Nacos at startup, but Local Standby capacity and expiration tuning cannot take effect without a restart. This change makes the supported Local Standby tuning fields update at runtime while keeping enable/disable switches static.
Implementation
ConfigServicewith validated, typed projections and last-known-good protection.maximum_entriescapacity_multiplierttl_msminimum_ttl_msttl_reduction_start_ratioautoSwitchEnabledand other enable/disable settings startup-only.Validation
./mvnw test./mvnw spotless:check -Pspotless-check./mvnw -Pinternal -pl flexlb-api -am package -DskipTests.minimum_ttl_ms > ttl_mswas rejected and the last-known-good configuration remained active.Compatibility and rollout
ConfigServicevalidation and notification path.Rollback
Restore the prior Nacos configuration values. The next accepted update restores the previous Local Standby runtime settings without requiring a restart.