AlarmServiceConcurrencyTests.cs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. using Xunit;
  2. using Serilog.Events;
  3. using YZWater.Core.Models;
  4. using YZWater.Core.Services;
  5. namespace YZWater.Core.Tests.Services;
  6. /// <summary>
  7. /// 报警内存状态的并发回归测试。
  8. /// 背景:轮询线程(CheckAlarms)、UI 线程(ViewD 搁置/复位)、SignalR 线程(远程复位)会同时读写
  9. /// _activeAlarms / _alarmStartTimes / _escalatedAlarms 这几个普通集合。改动前它们完全没有同步,
  10. /// 一边枚举一边被写入就直接抛 "Collection was modified",异常被 Avalonia 的 UI 兜底吞掉后
  11. /// 表现为"报警列表点不开 / 复位了但还在报警",界面上看不出任何线索。
  12. /// 这里压两件事:并发不炸不卡,且同一个条件不会被重复触发。
  13. /// </summary>
  14. [Collection("Serilog minimum level")]
  15. public class AlarmServiceConcurrencyTests : IDisposable
  16. {
  17. private readonly LogEventLevel _originalLevel = LogService.MinimumLevel;
  18. private readonly AlarmService _service;
  19. public AlarmServiceConcurrencyTests()
  20. {
  21. // 状态变更会记 Warning(报警触发/泛溢),压掉以免污染测试输出
  22. LogService.ApplyMinimumLevel("Error");
  23. _service = new AlarmService(PlcPollingService.Instance);
  24. _service.AlarmDelaySeconds = 0; // 立即触发,让压测真正走到触发分支而不是只登记起始时间
  25. }
  26. public void Dispose()
  27. {
  28. _service.Dispose();
  29. LogService.ApplyMinimumLevel(_originalLevel.ToString());
  30. }
  31. /// <summary>
  32. /// 评估、复位、搁置、读快照四路并发:不能抛异常,也不能卡死
  33. /// (卡死意味着锁里做了落库/事件回调之类的外部调用,与等锁的 UI 线程互等)。
  34. /// </summary>
  35. [Fact]
  36. public async Task ConcurrentEvaluateResetShelveRead_DoesNotCorruptState()
  37. {
  38. var workers = new List<Task>();
  39. // ① 两路"轮询线程":反复评估条件成立,偶尔让一半条件恢复(触发清除分支)
  40. for (var t = 0; t < 2; t++)
  41. {
  42. workers.Add(Task.Run(() =>
  43. {
  44. for (var round = 0; round < 200; round++)
  45. {
  46. for (var i = 0; i < 20; i++)
  47. _service.CheckCustomCondition($"Tank{i}_High", true, $"液位高 {i}", 2);
  48. // 每 20 轮清一次:既制造"边枚举边写"的窗口,又不会把报警表灌满
  49. if (round % 20 == 19)
  50. for (var i = 0; i < 20; i += 2)
  51. _service.CheckCustomCondition($"Tank{i}_High", false, string.Empty, 0);
  52. }
  53. }));
  54. }
  55. // ② "UI 线程":读快照属性(内部要遍历活跃集合)
  56. workers.Add(Task.Run(() =>
  57. {
  58. for (var round = 0; round < 2000; round++)
  59. {
  60. _ = _service.HasActiveAlarm;
  61. _ = _service.ActiveAlarmCount;
  62. _ = _service.ActiveAlarmMessage;
  63. }
  64. }));
  65. // ③ "UI 线程":搁置 / 取消搁置
  66. workers.Add(Task.Run(() =>
  67. {
  68. for (var round = 0; round < 300; round++)
  69. {
  70. var key = $"Shelve{round % 7}_High";
  71. _service.Shelve(key, 30, "Tester");
  72. _service.Unshelve(key, "Tester");
  73. }
  74. }));
  75. // ④ "远程客户端":全部复位(清空同一批集合并回写数据库)
  76. workers.Add(Task.Run(async () =>
  77. {
  78. for (var round = 0; round < 30; round++)
  79. await _service.ResetAllAlarmsAsync("Tester");
  80. }));
  81. var all = Task.WhenAll(workers);
  82. var finished = await Task.WhenAny(all, Task.Delay(TimeSpan.FromSeconds(60)));
  83. Assert.True(finished == all, "并发压测未在 60 秒内结束,疑似锁内做外部调用导致互等");
  84. await all; // 任一 worker 抛出 InvalidOperationException 等异常时让测试失败
  85. // 状态没被压坏:清空后重新评估仍能触发,并如实反映到快照属性
  86. await _service.ResetAllAlarmsAsync("Tester");
  87. _service.CheckCustomCondition("AfterStress_High", true, "压测后仍可触发", 2);
  88. _service.CheckCustomCondition("AfterStress_High", true, "压测后仍可触发", 2);
  89. Assert.True(_service.HasActiveAlarm);
  90. Assert.Equal(1, _service.ActiveAlarmCount);
  91. Assert.Contains("AfterStress_High", _service.ActiveAlarmMessage);
  92. }
  93. /// <summary>
  94. /// 同一条件被多个线程同时判定成立时只能触发一次 ——
  95. /// "是否已在报警"的检查和"占住活跃集合"必须在同一把锁里,否则两边都能通过检查,
  96. /// 报警列表和声音通知会收到重复的同一条报警。
  97. /// </summary>
  98. [Fact]
  99. public async Task SameCondition_RaisedOnlyOnceUnderConcurrentThreads()
  100. {
  101. const string conditionId = "ConcurrentTank_High";
  102. var raised = new List<AlarmRecord>();
  103. var raisedLock = new object();
  104. void OnRaised(AlarmRecord alarm)
  105. {
  106. lock (raisedLock) raised.Add(alarm);
  107. }
  108. _service.AlarmRaised += OnRaised;
  109. try
  110. {
  111. var workers = new List<Task>();
  112. for (var t = 0; t < 4; t++)
  113. {
  114. workers.Add(Task.Run(() =>
  115. {
  116. // 第一轮只登记起始时间,之后每一轮都是"条件仍成立"的重复评估
  117. for (var round = 0; round < 300; round++)
  118. _service.CheckCustomCondition(conditionId, true, "液位高", 2);
  119. }));
  120. }
  121. await Task.WhenAll(workers);
  122. }
  123. finally
  124. {
  125. _service.AlarmRaised -= OnRaised;
  126. }
  127. int duplicates;
  128. lock (raisedLock)
  129. duplicates = raised.Count(a => a.ConditionId == conditionId);
  130. Assert.Equal(1, duplicates);
  131. }
  132. /// <summary>
  133. /// 搁置期间不能因为并发而漏网触发(判定搁置与占住活跃集合在同一临界区内才成立)。
  134. /// </summary>
  135. [Fact]
  136. public async Task ShelvedCondition_IsNotRaisedEvenWhenEvaluatedConcurrently()
  137. {
  138. const string conditionId = "ShelvedTank_High";
  139. var raised = 0;
  140. void OnRaised(AlarmRecord alarm)
  141. {
  142. if (alarm.ConditionId == conditionId) Interlocked.Increment(ref raised);
  143. }
  144. _service.AlarmRaised += OnRaised;
  145. try
  146. {
  147. _service.Shelve(conditionId, 30, "Tester");
  148. var workers = new List<Task>();
  149. for (var t = 0; t < 4; t++)
  150. {
  151. workers.Add(Task.Run(() =>
  152. {
  153. for (var round = 0; round < 300; round++)
  154. _service.CheckCustomCondition(conditionId, true, "液位高", 2);
  155. }));
  156. }
  157. await Task.WhenAll(workers);
  158. }
  159. finally
  160. {
  161. _service.AlarmRaised -= OnRaised;
  162. _service.Unshelve(conditionId, "Tester");
  163. }
  164. Assert.Equal(0, raised);
  165. }
  166. /// <summary>
  167. /// 复位后条件仍成立时必须能重新触发,不能留下"已复位但界面仍报警"的脏状态。
  168. /// </summary>
  169. [Fact]
  170. public async Task ResetWhileConditionStillActive_LetsNextPollReRaise()
  171. {
  172. const string conditionId = "ResetThenRaise_High";
  173. _service.CheckCustomCondition(conditionId, true, "液位高", 2);
  174. _service.CheckCustomCondition(conditionId, true, "液位高", 2);
  175. Assert.True(_service.HasActiveAlarm);
  176. await _service.ResetAllAlarmsAsync("Tester");
  177. Assert.False(_service.HasActiveAlarm);
  178. _service.CheckCustomCondition(conditionId, true, "液位高", 2);
  179. _service.CheckCustomCondition(conditionId, true, "液位高", 2);
  180. Assert.Contains(conditionId, _service.ActiveAlarmMessage);
  181. }
  182. }