Files
ModelRouter/internal/core/channels_ttl_cache_test.go
JianFeeeee 420379784b feat(channels): 抽通用件 TTL 缓存 + 串行锁(照参考 ttl-cache / serial-queue)
理由:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"逻辑
(远端目录闸门、余额 60s 缓存、登录轮询…)。手写版本各写各的,边界条件容易漏
—— 本轮就漏了一个(见下)。集中一处才好审。

已把余额缓存改用 ttlCacheGet(消掉手写的 60s 逻辑)。

★ 抽出来立刻验证出价值 —— 实测踩到一个边界:
**清缓存时必须一并清掉"在飞标记"**。否则「清缓存 → 同一秒内重载 → 被去重逻辑
挡住 → 返回 nil」,调用方看到的是"刚清了缓存却拿不到值",而数据其实好好的。
(改动窗口后立即查余额就得到 nil,就是这个。)失效的语义是"忘掉这次加载",
在飞标记也是这次加载的一部分。

另外两条语义也写进注释与判据:
- **失败不缓存**:loader 返回 nil 时不写缓存(让下次能重试),但记在飞标记
  防止同一瞬间重复打。这与"失败进冷却"是两件事(冷却由调用方决定)。
- **墙钟回拨防护**:`cached_at` 在未来时视为已过期,而不是"还没到" ——
  回拨一天会让缓存永远命中、上游改了也自愈不了。

Lua 无协程 ⇒ 做不了"共享同一个 Promise"也做不了"排队等待"。取舍:
- 去重用**时间戳**(in_flight_at 短窗口)等价实现;
- 串行锁只能做到**互斥 + 让路**(抢不到立刻返回,由调用方跳过本次),
  且必须带 **TTL 自解**(持有者崩了不能永久卡死该 namespace)。

判据 `TestTtlCacheSemantics` 四组:命中缓存不重载、**失效后同一秒内能重载**、
ttl=0 每次重载、串行锁"抢到→抢不到→释放后能再抢"。
回归:9 个包全绿。
2026-10-14 16:09:22 +08:00

101 lines
3.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"testing"
)
// 通用件:TTL 缓存 + 串行锁(照参考 ttl-cache.ts / serial-queue.ts)。
//
// 抽它们出来的理由:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"
// 逻辑(远端目录闸门、余额 60s 缓存、登录轮询…)。手写版本各写各的,
// 边界条件容易漏 —— 实测就漏了一个(见下面 ③)。
func TestTtlCacheSemantics(t *testing.T) {
_, ps := poolCore(t)
if _, err := ps.Call("channels", "__ttl_reset_probe", nil); err != nil {
t.Fatalf("重置: %v", err)
}
// ① 首次加载后命中缓存:loader 只该被调一次
out, err := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 60,
})
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
if j["value"] != "v1" {
t.Errorf("首次应加载出 v1,实得 %v", j["value"])
}
if n, _ := j["loads"].(float64); n != 1 {
t.Errorf("首次后 loads 应为 1,实得 %v", n)
}
// 再取一次:命中缓存,loads 不变
out2, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 60,
})
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
if n, _ := j2["loads"].(float64); n != 1 {
t.Errorf("命中缓存时不该再加载,loads 实得 %v", n)
}
// ② 失效后必须能重新加载(包括同一秒内 —— 见 ③)
if _, err := ps.Call("channels", "__ttl_invalidate_probe", map[string]interface{}{
"ns": "t", "key": "k",
}); err != nil {
t.Fatalf("失效: %v", err)
}
out3, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 60,
})
j3, _ := out3.(map[string]interface{})["json"].(map[string]interface{})
// ⚠️ 这是实测踩到的坑:只清缓存、不清"在飞标记"时,同一秒内的重载会被
// 去重逻辑挡住,返回 nil —— 表现为"刚清了缓存却拿不到值"。
if j3["value"] == nil {
t.Fatalf("失效后立刻重载必须拿到值(清缓存时也要清在飞标记),实得 nil")
}
if n, _ := j3["loads"].(float64); n != 2 {
t.Errorf("失效后应重新加载(loads=2),实得 %v", n)
}
// ③ TTL=0 视为立即过期:不该命中
if _, err := ps.Call("channels", "__ttl_invalidate_probe", map[string]interface{}{
"ns": "t", "key": "k",
}); err != nil {
t.Fatalf("失效: %v", err)
}
_, _ = ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 0,
})
out5, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 0,
})
j5, _ := out5.(map[string]interface{})["json"].(map[string]interface{})
if n, _ := j5["loads"].(float64); n < 3 {
t.Errorf("ttl=0 不该命中缓存(每次都要重载),loads 实得 %v", n)
}
// ④ 串行锁:抢到 → 第二次抢不到 → 释放后能再抢
a, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
"ns": "s", "ttl": 60})
ja, _ := a.(map[string]interface{})["json"].(map[string]interface{})
if ja["acquired"] != true {
t.Errorf("首次应抢到,实得 %v", ja["acquired"])
}
b, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
"ns": "s", "ttl": 60})
jb, _ := b.(map[string]interface{})["json"].(map[string]interface{})
if jb["acquired"] != false {
t.Errorf("持有期间第二次不该抢到(这就是串行的意义),实得 %v", jb["acquired"])
}
if _, err := ps.Call("channels", "__serial_release_probe", map[string]interface{}{
"ns": "s"}); err != nil {
t.Fatalf("释放: %v", err)
}
c, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
"ns": "s", "ttl": 60})
jc, _ := c.(map[string]interface{})["json"].(map[string]interface{})
if jc["acquired"] != true {
t.Errorf("释放后应能再抢到,实得 %v", jc["acquired"])
}
}