| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- using System.Text.RegularExpressions;
- using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
- using Microsoft.Extensions.Logging.Abstractions;
- using SqlSugar;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
- /// <summary>
- /// S5 库存冷链跨实例互斥锁(<see cref="InventoryInboundLockGuard"/>)实库测试。
- /// <para>**门禁**:仅当环境变量 AIDOP_IT=1 时执行。</para>
- /// <para>
- /// 只碰 MySQL 咨询锁(GET_LOCK/RELEASE_LOCK/IS_USED_LOCK),**不读不写任何业务表**;
- /// 全部用例使用带 GUID 的一次性锁键,绝不触碰生产键 <c>aidop:s5:inventory-inbound</c>。
- /// </para>
- /// </summary>
- [Trait("Category", "Integration")]
- public class InventoryInboundLockGuardTests
- {
- private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
- private readonly Xunit.Abstractions.ITestOutputHelper _out;
- public InventoryInboundLockGuardTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
- /// <summary>
- /// 一次性测试锁键。**必须 ≤ 64 字符** —— MySQL 8.0 对 GET_LOCK 的 user-level lock name
- /// 有 64 字符硬上限,超长会直接报 "Incorrect user-level lock name" 而不是返回 0,
- /// 表现得像「取不到锁」。故这里只取 GUID 前 8 位,长度约 30 字符,仍足够隔离并发测试。
- /// (生产键 aidop:s5:inventory-inbound 为 26 字符,不受此限影响。)
- /// </summary>
- private static string NewKey(string tag) => $"aidop:t:inv-in:{tag}:{Guid.NewGuid():N}"[..30];
- private static string ReadAidopdevConnectionString()
- {
- const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
- if (!File.Exists(path)) return null;
- return File.ReadLines(path)
- .Select(l => l.Trim())
- .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"") && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
- .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
- .FirstOrDefault(v => !string.IsNullOrEmpty(v));
- }
- /// <summary>模拟「业务共享客户端」:与 SqlSugarSetup.cs:128 一致的 IsAutoCloseConnection=true。</summary>
- private static ISqlSugarClient BuildSharedClient(string cs) =>
- new SqlSugarScope(new ConnectionConfig
- {
- ConfigId = "aidopdev-invlock-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
- });
- /// <summary>模拟「另一个 Web 实例」:独立且不自动关闭的连接,会话全程持有。</summary>
- private static SqlSugarClient BuildDedicatedClient(string cs) =>
- new(new ConnectionConfig
- {
- DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = false,
- });
- private static async Task<long?> IsUsedLockAsync(ISqlSugarClient probe, string key)
- {
- var dt = await probe.Ado.GetDataTableAsync(
- "SELECT IS_USED_LOCK(@k)", new List<SugarParameter> { new("@k", key) });
- if (dt == null || dt.Rows.Count == 0) return null;
- var v = dt.Rows[0][0];
- return v == DBNull.Value ? (long?)null : Convert.ToInt64(v);
- }
- // ===== Case 1:并发互斥 —— A 进临界区,B 进不来;A 释放后 C 能进 =====
- [Fact]
- public async Task Case1_SecondCallerIsRejectedImmediately_ThirdSucceedsAfterRelease()
- {
- if (!Enabled) return;
- var cs = ReadAidopdevConnectionString();
- Assert.False(string.IsNullOrEmpty(cs));
- var db = BuildSharedClient(cs);
- var key = NewKey("case1");
- var started = DateTime.UtcNow;
- await using (var a = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key))
- {
- Assert.True(a.Acquired, $"acquire failed: {a.BusyReason}");
- Assert.NotNull(a.OwnerConnectionId);
- // B:临界区内的第二个并发请求必须立即被拒,不排队
- var b = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
- await using (b)
- {
- Assert.False(b.Acquired);
- Assert.False(string.IsNullOrEmpty(b.BusyReason));
- _out.WriteLine($"B rejected: {b.BusyReason}");
- }
- // 非阻塞语义:整段不得出现 GET_LOCK 的等待
- Assert.True((DateTime.UtcNow - started).TotalSeconds < 20);
- }
- // C:A 释放后必须能进
- await using var c = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
- Assert.True(c.Acquired, $"acquire failed: {c.BusyReason}");
- }
- // ===== Case 2:回归断言 —— 任务运行中 IS_USED_LOCK 非 NULL,结束后 NULL =====
- // 这正是原缺陷的判据:旧实现在共享连接上取锁,取完连接回池被 reset,IS_USED_LOCK 恒为 NULL。
- [Fact]
- public async Task Case2_LockIsVisibleToOtherSessions_WhileHeld_AndGoneAfterRelease()
- {
- if (!Enabled) return;
- var cs = ReadAidopdevConnectionString();
- Assert.False(string.IsNullOrEmpty(cs));
- var db = BuildSharedClient(cs);
- var key = NewKey("case2");
- // 用一条独立连接当「观察者」,模拟另一个实例来看锁在不在
- using var probe = BuildDedicatedClient(cs);
- long? ownerWhileHeld;
- long? guardOwner;
- await using (var a = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key))
- {
- Assert.True(a.Acquired, $"acquire failed: {a.BusyReason}");
- guardOwner = a.OwnerConnectionId;
- // 期间在共享 db 上再跑几条普通查询(会不断借还连接),锁必须不受影响
- await db.Ado.GetDataTableAsync("SELECT 1");
- await db.Ado.GetDataTableAsync("SELECT 2");
- ownerWhileHeld = await IsUsedLockAsync(probe, key);
- }
- var ownerAfter = await IsUsedLockAsync(probe, key);
- _out.WriteLine($"guardOwner={guardOwner} whileHeld={ownerWhileHeld} after={ownerAfter}");
- Assert.NotNull(ownerWhileHeld); // 修复前这里是 NULL —— 缺陷复现点
- Assert.Equal(guardOwner, ownerWhileHeld); // 持有者必须就是 guard 的专用连接
- Assert.Null(ownerAfter); // 释放后必须干净
- }
- // ===== Case 3:异常路径 —— 临界区内抛异常,finally(await using) 必须释放,后续可继续 =====
- [Fact]
- public async Task Case3_ExceptionInsideCriticalSection_StillReleasesLock()
- {
- if (!Enabled) return;
- var cs = ReadAidopdevConnectionString();
- Assert.False(string.IsNullOrEmpty(cs));
- var db = BuildSharedClient(cs);
- var key = NewKey("case3");
- using var probe = BuildDedicatedClient(cs);
- await Assert.ThrowsAsync<InvalidOperationException>(async () =>
- {
- await using var a = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
- Assert.True(a.Acquired, $"acquire failed: {a.BusyReason}");
- throw new InvalidOperationException("boom: 模拟 transform 中途失败");
- });
- // 锁必须已经释放:DB 侧看不到,且下一次能取到
- Assert.Null(await IsUsedLockAsync(probe, key));
- await using var next = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
- Assert.True(next.Acquired, $"acquire failed: {next.BusyReason}");
- }
- // ===== Case 4:跨实例互斥 —— 「另一个实例」持锁时,本实例必须被拒(绕过进程内闸门) =====
- [Fact]
- public async Task Case4_WhenAnotherInstanceHoldsLock_AcquireIsRejectedCrossInstance()
- {
- if (!Enabled) return;
- var cs = ReadAidopdevConnectionString();
- Assert.False(string.IsNullOrEmpty(cs));
- var db = BuildSharedClient(cs);
- var key = NewKey("case4");
- // 「实例 2」:独立会话持锁不放
- using var other = BuildDedicatedClient(cs);
- var dt = await other.Ado.GetDataTableAsync(
- "SELECT GET_LOCK(@k, 0)", new List<SugarParameter> { new("@k", key) });
- Assert.Equal(1L, Convert.ToInt64(dt.Rows[0][0]));
- try
- {
- await using var mine = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
- Assert.False(mine.Acquired);
- Assert.Equal("cross-instance busy", mine.BusyReason);
- }
- finally
- {
- await other.Ado.ExecuteCommandAsync(
- "SELECT RELEASE_LOCK(@k)", new List<SugarParameter> { new("@k", key) });
- }
- // 对方释放后本实例必须能取到
- await using var after = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
- Assert.True(after.Acquired, $"acquire failed: {after.BusyReason}");
- }
- // ===== Case 5:缺陷本体的负向留证 —— 旧写法(共享自动关闭连接)取的锁当场蒸发 =====
- [Fact]
- public async Task Case5_LegacyPattern_OnAutoCloseSharedClient_DoesNotHoldLock()
- {
- if (!Enabled) return;
- var cs = ReadAidopdevConnectionString();
- Assert.False(string.IsNullOrEmpty(cs));
- var legacy = BuildSharedClient(cs); // IsAutoCloseConnection = true —— 旧实现
- var key = NewKey("case5-legacy");
- using var probe = BuildDedicatedClient(cs);
- // 旧实现的取锁语句
- var got = await legacy.Ado.GetIntAsync(
- "SELECT GET_LOCK(@k, 0)", new List<SugarParameter> { new("@k", key) });
- Assert.Equal(1, got);
- // 命令一结束连接即回池并被驱动 reset,MySQL 释放该会话咨询锁 → 其它会话看不到任何持有者
- var owner = await IsUsedLockAsync(probe, key);
- _out.WriteLine("legacy owner after GET_LOCK = " + (owner.HasValue ? owner.Value.ToString() : "NULL"));
- Assert.Null(owner);
- // 因此另一个实例可以毫无阻碍地拿到同一把锁 —— 这就是 P0 缺陷
- await using var second = await InventoryInboundLockGuard.TryAcquireAsync(legacy, NullLogger.Instance, key);
- Assert.True(second.Acquired, $"acquire failed: {second.BusyReason}");
- }
- }
|