InventoryInboundLockGuardTests.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. using System.Text.RegularExpressions;
  2. using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  3. using Microsoft.Extensions.Logging.Abstractions;
  4. using SqlSugar;
  5. using Xunit;
  6. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  7. /// <summary>
  8. /// S5 库存冷链跨实例互斥锁(<see cref="InventoryInboundLockGuard"/>)实库测试。
  9. /// <para>**门禁**:仅当环境变量 AIDOP_IT=1 时执行。</para>
  10. /// <para>
  11. /// 只碰 MySQL 咨询锁(GET_LOCK/RELEASE_LOCK/IS_USED_LOCK),**不读不写任何业务表**;
  12. /// 全部用例使用带 GUID 的一次性锁键,绝不触碰生产键 <c>aidop:s5:inventory-inbound</c>。
  13. /// </para>
  14. /// </summary>
  15. [Trait("Category", "Integration")]
  16. public class InventoryInboundLockGuardTests
  17. {
  18. private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
  19. private readonly Xunit.Abstractions.ITestOutputHelper _out;
  20. public InventoryInboundLockGuardTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
  21. /// <summary>
  22. /// 一次性测试锁键。**必须 ≤ 64 字符** —— MySQL 8.0 对 GET_LOCK 的 user-level lock name
  23. /// 有 64 字符硬上限,超长会直接报 "Incorrect user-level lock name" 而不是返回 0,
  24. /// 表现得像「取不到锁」。故这里只取 GUID 前 8 位,长度约 30 字符,仍足够隔离并发测试。
  25. /// (生产键 aidop:s5:inventory-inbound 为 26 字符,不受此限影响。)
  26. /// </summary>
  27. private static string NewKey(string tag) => $"aidop:t:inv-in:{tag}:{Guid.NewGuid():N}"[..30];
  28. private static string ReadAidopdevConnectionString()
  29. {
  30. const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
  31. if (!File.Exists(path)) return null;
  32. return File.ReadLines(path)
  33. .Select(l => l.Trim())
  34. .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"") && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
  35. .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
  36. .FirstOrDefault(v => !string.IsNullOrEmpty(v));
  37. }
  38. /// <summary>模拟「业务共享客户端」:与 SqlSugarSetup.cs:128 一致的 IsAutoCloseConnection=true。</summary>
  39. private static ISqlSugarClient BuildSharedClient(string cs) =>
  40. new SqlSugarScope(new ConnectionConfig
  41. {
  42. ConfigId = "aidopdev-invlock-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
  43. });
  44. /// <summary>模拟「另一个 Web 实例」:独立且不自动关闭的连接,会话全程持有。</summary>
  45. private static SqlSugarClient BuildDedicatedClient(string cs) =>
  46. new(new ConnectionConfig
  47. {
  48. DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = false,
  49. });
  50. private static async Task<long?> IsUsedLockAsync(ISqlSugarClient probe, string key)
  51. {
  52. var dt = await probe.Ado.GetDataTableAsync(
  53. "SELECT IS_USED_LOCK(@k)", new List<SugarParameter> { new("@k", key) });
  54. if (dt == null || dt.Rows.Count == 0) return null;
  55. var v = dt.Rows[0][0];
  56. return v == DBNull.Value ? (long?)null : Convert.ToInt64(v);
  57. }
  58. // ===== Case 1:并发互斥 —— A 进临界区,B 进不来;A 释放后 C 能进 =====
  59. [Fact]
  60. public async Task Case1_SecondCallerIsRejectedImmediately_ThirdSucceedsAfterRelease()
  61. {
  62. if (!Enabled) return;
  63. var cs = ReadAidopdevConnectionString();
  64. Assert.False(string.IsNullOrEmpty(cs));
  65. var db = BuildSharedClient(cs);
  66. var key = NewKey("case1");
  67. var started = DateTime.UtcNow;
  68. await using (var a = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key))
  69. {
  70. Assert.True(a.Acquired, $"acquire failed: {a.BusyReason}");
  71. Assert.NotNull(a.OwnerConnectionId);
  72. // B:临界区内的第二个并发请求必须立即被拒,不排队
  73. var b = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
  74. await using (b)
  75. {
  76. Assert.False(b.Acquired);
  77. Assert.False(string.IsNullOrEmpty(b.BusyReason));
  78. _out.WriteLine($"B rejected: {b.BusyReason}");
  79. }
  80. // 非阻塞语义:整段不得出现 GET_LOCK 的等待
  81. Assert.True((DateTime.UtcNow - started).TotalSeconds < 20);
  82. }
  83. // C:A 释放后必须能进
  84. await using var c = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
  85. Assert.True(c.Acquired, $"acquire failed: {c.BusyReason}");
  86. }
  87. // ===== Case 2:回归断言 —— 任务运行中 IS_USED_LOCK 非 NULL,结束后 NULL =====
  88. // 这正是原缺陷的判据:旧实现在共享连接上取锁,取完连接回池被 reset,IS_USED_LOCK 恒为 NULL。
  89. [Fact]
  90. public async Task Case2_LockIsVisibleToOtherSessions_WhileHeld_AndGoneAfterRelease()
  91. {
  92. if (!Enabled) return;
  93. var cs = ReadAidopdevConnectionString();
  94. Assert.False(string.IsNullOrEmpty(cs));
  95. var db = BuildSharedClient(cs);
  96. var key = NewKey("case2");
  97. // 用一条独立连接当「观察者」,模拟另一个实例来看锁在不在
  98. using var probe = BuildDedicatedClient(cs);
  99. long? ownerWhileHeld;
  100. long? guardOwner;
  101. await using (var a = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key))
  102. {
  103. Assert.True(a.Acquired, $"acquire failed: {a.BusyReason}");
  104. guardOwner = a.OwnerConnectionId;
  105. // 期间在共享 db 上再跑几条普通查询(会不断借还连接),锁必须不受影响
  106. await db.Ado.GetDataTableAsync("SELECT 1");
  107. await db.Ado.GetDataTableAsync("SELECT 2");
  108. ownerWhileHeld = await IsUsedLockAsync(probe, key);
  109. }
  110. var ownerAfter = await IsUsedLockAsync(probe, key);
  111. _out.WriteLine($"guardOwner={guardOwner} whileHeld={ownerWhileHeld} after={ownerAfter}");
  112. Assert.NotNull(ownerWhileHeld); // 修复前这里是 NULL —— 缺陷复现点
  113. Assert.Equal(guardOwner, ownerWhileHeld); // 持有者必须就是 guard 的专用连接
  114. Assert.Null(ownerAfter); // 释放后必须干净
  115. }
  116. // ===== Case 3:异常路径 —— 临界区内抛异常,finally(await using) 必须释放,后续可继续 =====
  117. [Fact]
  118. public async Task Case3_ExceptionInsideCriticalSection_StillReleasesLock()
  119. {
  120. if (!Enabled) return;
  121. var cs = ReadAidopdevConnectionString();
  122. Assert.False(string.IsNullOrEmpty(cs));
  123. var db = BuildSharedClient(cs);
  124. var key = NewKey("case3");
  125. using var probe = BuildDedicatedClient(cs);
  126. await Assert.ThrowsAsync<InvalidOperationException>(async () =>
  127. {
  128. await using var a = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
  129. Assert.True(a.Acquired, $"acquire failed: {a.BusyReason}");
  130. throw new InvalidOperationException("boom: 模拟 transform 中途失败");
  131. });
  132. // 锁必须已经释放:DB 侧看不到,且下一次能取到
  133. Assert.Null(await IsUsedLockAsync(probe, key));
  134. await using var next = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
  135. Assert.True(next.Acquired, $"acquire failed: {next.BusyReason}");
  136. }
  137. // ===== Case 4:跨实例互斥 —— 「另一个实例」持锁时,本实例必须被拒(绕过进程内闸门) =====
  138. [Fact]
  139. public async Task Case4_WhenAnotherInstanceHoldsLock_AcquireIsRejectedCrossInstance()
  140. {
  141. if (!Enabled) return;
  142. var cs = ReadAidopdevConnectionString();
  143. Assert.False(string.IsNullOrEmpty(cs));
  144. var db = BuildSharedClient(cs);
  145. var key = NewKey("case4");
  146. // 「实例 2」:独立会话持锁不放
  147. using var other = BuildDedicatedClient(cs);
  148. var dt = await other.Ado.GetDataTableAsync(
  149. "SELECT GET_LOCK(@k, 0)", new List<SugarParameter> { new("@k", key) });
  150. Assert.Equal(1L, Convert.ToInt64(dt.Rows[0][0]));
  151. try
  152. {
  153. await using var mine = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
  154. Assert.False(mine.Acquired);
  155. Assert.Equal("cross-instance busy", mine.BusyReason);
  156. }
  157. finally
  158. {
  159. await other.Ado.ExecuteCommandAsync(
  160. "SELECT RELEASE_LOCK(@k)", new List<SugarParameter> { new("@k", key) });
  161. }
  162. // 对方释放后本实例必须能取到
  163. await using var after = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
  164. Assert.True(after.Acquired, $"acquire failed: {after.BusyReason}");
  165. }
  166. // ===== Case 5:缺陷本体的负向留证 —— 旧写法(共享自动关闭连接)取的锁当场蒸发 =====
  167. [Fact]
  168. public async Task Case5_LegacyPattern_OnAutoCloseSharedClient_DoesNotHoldLock()
  169. {
  170. if (!Enabled) return;
  171. var cs = ReadAidopdevConnectionString();
  172. Assert.False(string.IsNullOrEmpty(cs));
  173. var legacy = BuildSharedClient(cs); // IsAutoCloseConnection = true —— 旧实现
  174. var key = NewKey("case5-legacy");
  175. using var probe = BuildDedicatedClient(cs);
  176. // 旧实现的取锁语句
  177. var got = await legacy.Ado.GetIntAsync(
  178. "SELECT GET_LOCK(@k, 0)", new List<SugarParameter> { new("@k", key) });
  179. Assert.Equal(1, got);
  180. // 命令一结束连接即回池并被驱动 reset,MySQL 释放该会话咨询锁 → 其它会话看不到任何持有者
  181. var owner = await IsUsedLockAsync(probe, key);
  182. _out.WriteLine("legacy owner after GET_LOCK = " + (owner.HasValue ? owner.Value.ToString() : "NULL"));
  183. Assert.Null(owner);
  184. // 因此另一个实例可以毫无阻碍地拿到同一把锁 —— 这就是 P0 缺陷
  185. await using var second = await InventoryInboundLockGuard.TryAcquireAsync(legacy, NullLogger.Instance, key);
  186. Assert.True(second.Acquired, $"acquire failed: {second.BusyReason}");
  187. }
  188. }