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, "稳态窗口内多次读取零重载(零查询)");
}
}
}
@@ -149,6 +149,12 @@ namespace YLErp.Modules.DataProviderModule
price = 0;
valueDate = valueDate.Date;
// FR007 走整表快照缓存(预载全历史,O(1) 命中零查询)。miss=行尚不存在(当日未发布/补录),
// 继续直查库兜底——miss 永不进快照(负缓存防线,详见 Fr007FixingCache 注释)。
if (underlyingCode == Fr007FixingCache.UnderlyingCode && Fr007FixingCache.TryGet(valueDate, out price))
return true;
using var db = DbContextFactory.GetYLDbContext();
var data = db.eod_commodity_future_price.Where(x => x.ValueDate == valueDate && x.UnderlyingCode == underlyingCode).OrderByDescending(o => o.ValueDate).FirstOrDefault();
@@ -0,0 +1,96 @@
using YLErp.Modules;
namespace YLErp.Modules.DataProviderModule
{
/// <summary>
/// FR007 定盘快照缓存。定盘是"每工作日一个数、发布后不变"的小表(一年约 250 行,全历史数千行),
/// 故采用整表快照而非逐键缓存:
///
/// <para>① <b>预载</b>:首次访问一次性 SELECT 全历史进字典,此后读取 O(1)、零查询;</para>
/// <para>② <b>miss 不进快照</b>(负缓存防线):当日定盘 ~11:15 后才出现、历史可补录——
/// 字典查不到必须回退直查库,绝不能把"查不到"缓存住,否则发布后仍沿用旧利率;</para>
/// <para>③ <b>写侧版本失效</b>:经应用的 FR007 写入(SwapFlowService 新增/修改/删除)立即失效,
/// 下次访问整表重载一次;</para>
/// <para>④ <b>TTL 1 分钟兜底</b>:覆盖直改库与其他通用价格写入口(导入/复制/合成等),
/// 可见性窗口 ≤1 分钟,重启无需。</para>
///
/// <para>快照以引用替换发布(Volatile.Write),读侧要么看到旧快照要么看到新快照,无撕裂;
/// 重载加锁去抖,避免并发下重复整表查询。</para>
/// </summary>
public static class Fr007FixingCache
{
public const string UnderlyingCode = "FR007";
/// <summary>TTL:兜底"直接改库/通用写入口"的可见性窗口(FR007 专属写入口走版本失效,不受此限)。</summary>
internal static TimeSpan Ttl = TimeSpan.FromMinutes(1);
/// <summary>测试接缝:时钟(默认 UtcNow Ticks)。</summary>
internal static Func<long> NowTicks = () => DateTime.UtcNow.Ticks;
/// <summary>测试接缝:快照装载器(默认直查库)。返回 日期→ReferencePrice 全量映射。</summary>
internal static Func<Dictionary<DateTime, double>> LoadSnapshot = LoadFromDb;
private static Dictionary<DateTime, double> _snapshot = new();
private static bool _loaded; // 是否已完成首次装载
private static long _loadedAt; // 快照装载时刻(NowTicks 口径)
private static long _writeVersion; // 写侧版本(Invalidate 递增)
private static long _snapVersion; // 装载时的写侧版本
private static readonly object _reloadLock = new();
private static Dictionary<DateTime, double> LoadFromDb()
{
using var db = DbContextFactory.GetYLDbContext();
return db.eod_commodity_future_price
.Where(x => x.UnderlyingCode == UnderlyingCode && x.ReferencePrice != null)
.Select(x => new { x.ValueDate, Price = x.ReferencePrice!.Value })
.AsEnumerable()
.GroupBy(x => x.ValueDate.Date)
.ToDictionary(g => g.Key, g => g.First().Price);
}
/// <summary>命中返回 true。miss 仅代表"快照里没有",调用方须直查库兜底(当日新发布/补录)。</summary>
public static bool TryGet(DateTime valueDate, out double price)
{
EnsureFresh();
return Volatile.Read(ref _snapshot).TryGetValue(valueDate.Date, out price);
}
/// <summary>写侧失效:FR007 行经应用新增/修改/删除后调用,下次访问整表重载。</summary>
public static void Invalidate() => Interlocked.Increment(ref _writeVersion);
/// <summary>重置为未装载状态并恢复默认接缝(仅测试用)。</summary>
internal static void ResetForTest()
{
lock (_reloadLock)
{
_snapshot = new Dictionary<DateTime, double>();
_loaded = false;
_loadedAt = 0;
_writeVersion = 0;
_snapVersion = 0;
Ttl = TimeSpan.FromMinutes(1);
NowTicks = () => DateTime.UtcNow.Ticks;
LoadSnapshot = LoadFromDb;
}
}
private static void EnsureFresh()
{
if (IsFresh()) return;
lock (_reloadLock)
{
if (IsFresh()) return;
var snap = LoadSnapshot();
Volatile.Write(ref _snapshot, snap);
Volatile.Write(ref _loaded, true);
Volatile.Write(ref _loadedAt, NowTicks());
Volatile.Write(ref _snapVersion, Volatile.Read(ref _writeVersion));
}
}
private static bool IsFresh()
=> Volatile.Read(ref _loaded)
&& Volatile.Read(ref _snapVersion) == Volatile.Read(ref _writeVersion)
&& NowTicks() - Volatile.Read(ref _loadedAt) <= Ttl.Ticks;
}
}
@@ -10,6 +10,7 @@ using YLErp.Helpers;
using YLErp.Model;
using YLErp.Model.Enum;
using YLErp.Modules.EodModule;
using YLErp.Modules.DataProviderModule;
using YLErp.Modules.SwapModule.Dto;
using YLErp.Office;
using YLErp.QdpModule;
@@ -70,6 +71,7 @@ namespace YLErp.Modules.SwapModule
}
DbContext.eod_commodity_future_price.Remove(frdata);
DbContext.SaveChanges();
Fr007FixingCache.Invalidate(); // 写侧失效:删除当日定盘后缓存立即重载,防旧值沿用
return true;
}
/// <summary>
@@ -115,6 +117,7 @@ namespace YLErp.Modules.SwapModule
frdata.OptName = UserInfo.UserName;
frdata.OptDate = DateTime.Now;
DbContext.SaveChanges();
Fr007FixingCache.Invalidate(); // 写侧失效:新增/修改定盘后缓存立即重载
return true;
}
+4
View File
@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
// 测试工程访问 internal 接缝(如 Fr007FixingCache 的时钟/装载器注入),替代把测试钩子暴露为 public。
[assembly: InternalsVisibleTo("UnitTestProject")]