feat(swap): FR007定盘整表快照缓存——预载全历史O(1)命中,miss不缓存,写侧失效+1min TTL兜底

数据形态:每工作日一行、发布后不变、全历史仅数千行 → 整表快照优于逐键缓存。
① Fr007FixingCache:首次访问批量SELECT全历史进字典,此后读取O(1)零查询;
   快照以引用替换发布(Volatile),读侧无撕裂,重载加锁去抖;
② 负缓存防线:miss(当日~11:15未发布/补录历史)永不进快照,TryGetPrice 回退直查库——
   否则发布后TTL窗口内仍查不到,上午平仓会一直用回退利率;
③ 写侧版本失效:SwapFlowService 新增/修改/删除 FR007 后 Invalidate,下次访问立即
   整表重载不等TTL;其余通用写入口(导入/复制/合成/直改库)靠TTL兜底,可见性≤1分钟;
④ TryGetPrice 仅 FR007 走缓存路由(全仓生产调用方仅 SwapDealService.TryGetFloatRate
   与 Fr007IndexFixer 两处,均定盘链),其他标的照旧直查,零影响;
⑤ YLErpDAL 增加 InternalsVisibleTo("UnitTestProject"),时钟/装载器测试接缝保持 internal。

验证:Fr007FixingCacheTest 5/5(预载命中/miss不缓存/写侧立即生效/TTL过期重载/稳态零重载);
GLMS20260817Fr007UnwindMorningTest 等内存套件 55/55 全绿。
This commit is contained in:
hjhan
2026-08-18 08:24:21 +08:00
parent a97476c4da
commit e219bdae18
5 changed files with 214 additions and 0 deletions
@@ -0,0 +1,105 @@
using YLErp.Modules.DataProviderModule;
namespace YLErp.Modules.DataProviderModule
{
/// <summary>
/// Fr007FixingCache 快照缓存行为契约(纯内存,不连库;Loader/时钟均为注入接缝):
/// ① 首次访问批量预载、命中 O(1);② miss 不进快照(负缓存防线——当日发布前 miss、发布后须能查到);
/// ③ 写侧版本失效:Invalidate 后立即重载取到新值(不等 TTL);④ TTL 过期自动重载(直改库兜底);
/// ⑤ TTL 窗口内无写入不重载(零查询稳态)。
/// </summary>
[TestClass]
public class Fr007FixingCacheTest
{
private static readonly DateTime D1 = new(2026, 7, 6);
private static readonly DateTime D2 = new(2026, 7, 13);
private int _loadCount;
private Dictionary<DateTime, double> _market;
[TestInitialize]
public void Init()
{
_loadCount = 0;
_market = new Dictionary<DateTime, double> { [D1] = 0.0142, [D2] = 0.01425 };
Fr007FixingCache.ResetForTest();
Fr007FixingCache.LoadSnapshot = () => { _loadCount++; return new Dictionary<DateTime, double>(_market); };
}
[TestCleanup]
public void Cleanup() => Fr007FixingCache.ResetForTest();
private static void AdvanceClock(long ticks) => Fr007FixingCache.NowTicks = () => ticks;
[TestMethod]
public void 访()
{
Fr007FixingCache.NowTicks = () => 1_000_000L;
Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p1), "预载后历史定盘应命中");
Assert.AreEqual(0.0142, p1, 1e-12);
Assert.AreEqual(1, _loadCount, "首次访问恰好装载一次");
Assert.IsTrue(Fr007FixingCache.TryGet(D2, out var p2), "同快照内多次命中");
Assert.AreEqual(0.01425, p2, 1e-12);
Assert.AreEqual(1, _loadCount, "命中不应重复装载");
}
[TestMethod]
public void miss不进快照_当日新发布经直查兜底后TTL内可见()
{
Fr007FixingCache.NowTicks = () => 1_000_000L;
var today = new DateTime(2026, 8, 18);
Assert.IsFalse(Fr007FixingCache.TryGet(today, out _), "快照无该行应 miss(调用方直查库兜底)");
Assert.AreEqual(1, _loadCount);
// 直查库发现了新发布的当日行 → 写侧失效(SwapFlowService 场景)→ 重载后可见
_market[today] = 0.0143;
Fr007FixingCache.Invalidate();
Assert.IsTrue(Fr007FixingCache.TryGet(today, out var p), "写侧失效重载后当日行应可见");
Assert.AreEqual(0.0143, p, 1e-12);
Assert.AreEqual(2, _loadCount);
}
[TestMethod]
public void _不等TTL()
{
Fr007FixingCache.NowTicks = () => 1_000_000L;
Fr007FixingCache.TryGet(D1, out _);
Assert.AreEqual(1, _loadCount);
_market[D1] = 0.0150; // 界面修正错价
Fr007FixingCache.Invalidate();
// 时钟只走了 1 tick(远小于 TTL),仍必须重载
Fr007FixingCache.NowTicks = () => 1_000_001L;
Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p), "修正后仍应命中");
Assert.AreEqual(0.0150, p, 1e-12, "TTL 未到也必须看到写侧修正值");
Assert.AreEqual(2, _loadCount, "版本失效应立即触发重载");
}
[TestMethod]
public void TTL过期自动重载_直改库兜底()
{
Fr007FixingCache.NowTicks = () => 1_000_000L;
Fr007FixingCache.TryGet(D1, out _);
Assert.AreEqual(1, _loadCount);
_market[D1] = 0.0160; // 直改库(无 Invalidate
Fr007FixingCache.NowTicks = () => 1_000_000L + TimeSpan.FromMinutes(1).Ticks + 1; // TTL+1 tick
Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p));
Assert.AreEqual(0.0160, p, 1e-12, "TTL 过期后应重载并看到直改库的新值");
Assert.AreEqual(2, _loadCount);
}
[TestMethod]
public void TTL窗口内无写入零重载()
{
Fr007FixingCache.NowTicks = () => 1_000_000L;
Fr007FixingCache.TryGet(D1, out _);
Fr007FixingCache.NowTicks = () => 1_000_000L + TimeSpan.FromMinutes(1).Ticks - 1; // TTL-1 tick:稳态窗口
for (int i = 0; i < 10; i++) Fr007FixingCache.TryGet(D1, out _);
Assert.AreEqual(1, _loadCount, "稳态窗口内多次读取零重载(零查询)");
}
}
}