ModuleRebuildServiceTests.cs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817
  1. using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
  2. using Admin.NET.Plugin.AiDOP.Entity.SmartOps;
  3. using Admin.NET.Plugin.AiDOP.Supply;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  6. public class ModuleRebuildServiceTests
  7. {
  8. private static void SeedSuccess(MemoryJobStore store, string module, long tenant, DateTime finishedAt)
  9. {
  10. store.Rows.Add(new AdoModuleDashboardRebuildJob
  11. {
  12. Id = 9000 + store.Rows.Count,
  13. ModuleCode = module,
  14. TenantId = tenant,
  15. FactoryId = 1,
  16. Status = ModuleRebuildStatus.Success,
  17. FinishedAt = finishedAt
  18. });
  19. }
  20. [Fact]
  21. public async Task AutoEnqueue_WithinCooldown_ReturnsCooldown()
  22. {
  23. var store = new MemoryJobStore();
  24. SeedSuccess(store, "S2", 9, DateTime.Now.AddHours(-1));
  25. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  26. var before = store.Rows.Count;
  27. var result = await svc.EnqueueAsync("S2", 9, 1, null, "AUTO");
  28. Assert.Equal(409, result.StatusCode);
  29. Assert.Equal("COOLDOWN", result.Body.Status);
  30. Assert.Equal(before, store.Rows.Count);
  31. }
  32. [Fact]
  33. public async Task AutoEnqueue_AfterCooldown_IsAccepted()
  34. {
  35. var store = new MemoryJobStore();
  36. SeedSuccess(store, "S2", 9, DateTime.Now.AddHours(-7));
  37. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  38. var result = await svc.EnqueueAsync("S2", 9, 1, null, "AUTO");
  39. Assert.Equal(202, result.StatusCode);
  40. }
  41. [Fact]
  42. public async Task ManualEnqueue_IsNeverCooledDown()
  43. {
  44. var store = new MemoryJobStore();
  45. SeedSuccess(store, "S2", 9, DateTime.Now.AddMinutes(-1));
  46. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  47. var result = await svc.EnqueueAsync("S2", 9, 1, null);
  48. Assert.Equal(202, result.StatusCode);
  49. }
  50. [Fact]
  51. public async Task ManualEnqueue_WithRequestedBy_IsNeverCooledDown()
  52. {
  53. var store = new MemoryJobStore();
  54. SeedSuccess(store, "S2", 9, DateTime.Now.AddMinutes(-1));
  55. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  56. var result = await svc.EnqueueAsync("S2", 9, 1, 123, "AUTO");
  57. Assert.Equal(202, result.StatusCode);
  58. }
  59. [Fact]
  60. public async Task NightlyEnqueue_BypassesCooldown()
  61. {
  62. var store = new MemoryJobStore();
  63. SeedSuccess(store, "S2", 9, DateTime.Now.AddMinutes(-1));
  64. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  65. var result = await svc.EnqueueAsync("S2", 9, 1, null, "AUTO_NIGHTLY");
  66. Assert.Equal(202, result.StatusCode);
  67. }
  68. [Fact]
  69. public async Task DbSyncEnqueue_BypassesCooldown()
  70. {
  71. var store = new MemoryJobStore();
  72. SeedSuccess(store, "S2", 9, DateTime.Now.AddMinutes(-1));
  73. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  74. var result = await svc.EnqueueAsync("S2", 9, 1, null, ModuleRebuildTriggerType.DbSync);
  75. Assert.Equal(202, result.StatusCode);
  76. }
  77. [Fact]
  78. public async Task BootstrapEnqueue_IsCooledDown()
  79. {
  80. var store = new MemoryJobStore();
  81. SeedSuccess(store, "S2", 9, DateTime.Now.AddMinutes(-1));
  82. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  83. var result = await svc.EnqueueAsync("S2", 9, 1, null, "BOOTSTRAP");
  84. Assert.Equal(409, result.StatusCode);
  85. Assert.Equal("COOLDOWN", result.Body.Status);
  86. }
  87. [Fact]
  88. public async Task CooldownZero_DisablesWindow()
  89. {
  90. var store = new MemoryJobStore();
  91. SeedSuccess(store, "S2", 9, DateTime.Now.AddMinutes(-1));
  92. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new ConfigurableCapability { AutoMinIntervalHours = 0 });
  93. var result = await svc.EnqueueAsync("S2", 9, 1, null, "AUTO");
  94. Assert.Equal(202, result.StatusCode);
  95. }
  96. [Fact]
  97. public async Task RunningJob_StillBlocksBeforeCooldownCheck()
  98. {
  99. var store = new MemoryJobStore();
  100. SeedSuccess(store, "S2", 9, DateTime.Now.AddMinutes(-1));
  101. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  102. await svc.EnqueueAsync("S2", 9, 1, null);
  103. var result = await svc.EnqueueAsync("S2", 9, 1, null, "AUTO");
  104. Assert.Equal(409, result.StatusCode);
  105. Assert.Equal(ModuleRebuildStatus.Queued, result.Body.Status);
  106. }
  107. /// <summary>
  108. /// 夜间兜底任务在非执行机上永远领不到(<c>ClaimNextQueuedAsync</c> 的白名单),
  109. /// 若它还能挡住人工入队,页面「数据重算」按钮就被永久卡死。人工请求必须能取代它。
  110. /// </summary>
  111. [Fact]
  112. public async Task ManualEnqueue_SupersedesQueuedAutoJob()
  113. {
  114. var store = new MemoryJobStore();
  115. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  116. var nightly = await svc.EnqueueAsync("S2", 9, 1, null, "AUTO_NIGHTLY");
  117. var manual = await svc.EnqueueAsync("S2", 9, 1, 7);
  118. Assert.Equal(202, manual.StatusCode);
  119. Assert.NotEqual(nightly.Body.JobId, manual.Body.JobId);
  120. var superseded = store.Rows.Single(x => x.Id == nightly.Body.JobId);
  121. Assert.Equal(ModuleRebuildStatus.Cancelled, superseded.Status);
  122. Assert.Contains("AUTO_NIGHTLY", superseded.ErrorMessage);
  123. }
  124. /// <summary>已经开跑的任务不得被掀掉:取代只针对 QUEUED。</summary>
  125. [Fact]
  126. public async Task ManualEnqueue_DoesNotSupersedeRunningAutoJob()
  127. {
  128. var store = new MemoryJobStore();
  129. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  130. await svc.EnqueueAsync("S2", 9, 1, null, "AUTO_NIGHTLY");
  131. var running = await store.ClaimNextQueuedAsync(new[] { "S2" });
  132. var manual = await svc.EnqueueAsync("S2", 9, 1, 7);
  133. Assert.Equal(409, manual.StatusCode);
  134. Assert.Equal(ModuleRebuildStatus.Running, store.Rows.Single(x => x.Id == running.Id).Status);
  135. }
  136. /// <summary>取代资格只给人工请求;自动触发器之间仍按原样互斥,避免夜间批次自相残杀。</summary>
  137. [Fact]
  138. public async Task AutoEnqueue_DoesNotSupersedeQueuedAutoJob()
  139. {
  140. var store = new MemoryJobStore();
  141. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new ConfigurableCapability { AutoMinIntervalHours = 0 });
  142. var nightly = await svc.EnqueueAsync("S2", 9, 1, null, "AUTO_NIGHTLY");
  143. var auto = await svc.EnqueueAsync("S2", 9, 1, null, "AUTO");
  144. Assert.Equal(409, auto.StatusCode);
  145. Assert.Equal(nightly.Body.JobId, auto.Body.JobId);
  146. Assert.Equal(ModuleRebuildStatus.Queued, store.Rows.Single(x => x.Id == nightly.Body.JobId).Status);
  147. }
  148. [Fact]
  149. public void S1Service_DelegatesToUnifiedPath()
  150. {
  151. var source = File.ReadAllText(RepoFile("server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "S1Refresh", "S1DashboardRebuildService.cs"));
  152. var unified = source.IndexOf("if (UseUnified)", StringComparison.Ordinal);
  153. Assert.True(unified >= 0, "S1 统一路径分支丢失");
  154. var body = source[unified..source.IndexOf("var scope = S1MdpRunScope.Create", unified, StringComparison.Ordinal)];
  155. Assert.Contains("_moduleRebuild.EnqueueAsync(", body);
  156. }
  157. [Fact]
  158. public void NightlyJob_UsesNonCollidingCron()
  159. {
  160. var source = File.ReadAllText(RepoFile("server", "Plugins", "Admin.NET.Plugin.AiDOP", "Job", "MdpNightlyFullRebuildJob.cs"));
  161. Assert.Contains("Cron(\"40 3 * * *\"", source);
  162. Assert.DoesNotContain("\"15 3\"", source);
  163. Assert.DoesNotContain("\"0 2\"", source);
  164. }
  165. [Theory]
  166. [InlineData("AUTO", false)]
  167. [InlineData("auto", false)]
  168. [InlineData("MANUAL", true)]
  169. [InlineData("AUTO_NIGHTLY", true)]
  170. [InlineData("BOOTSTRAP", true)]
  171. [InlineData("", true)]
  172. public void ShouldPullFull_OnlyAutoIsIncremental(string triggerType, bool expected)
  173. => Assert.Equal(expected, ModuleRebuildTriggerType.ShouldPullFull(triggerType));
  174. private static string RepoFile(params string[] parts)
  175. {
  176. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  177. while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md")))
  178. dir = dir.Parent;
  179. Assert.NotNull(dir);
  180. return Path.Combine(new[] { dir!.FullName }.Concat(parts).ToArray());
  181. }
  182. [Fact]
  183. public async Task SecondEnqueue_SameScope_ReturnsConflict()
  184. {
  185. var store = new MemoryJobStore();
  186. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  187. var first = await svc.EnqueueAsync("S2", 9, 1, 1);
  188. var second = await svc.EnqueueAsync("S2", 9, 1, 1);
  189. Assert.Equal(202, first.StatusCode);
  190. Assert.Equal(409, second.StatusCode);
  191. Assert.Equal(first.Body.JobId, second.Body.JobId);
  192. }
  193. [Fact]
  194. public async Task OtherTenantEnqueue_NotBlocked()
  195. {
  196. var store = new MemoryJobStore();
  197. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  198. var a = await svc.EnqueueAsync("S2", 9, 1, 1);
  199. var b = await svc.EnqueueAsync("S2", 10, 1, 2);
  200. Assert.Equal(202, a.StatusCode);
  201. Assert.Equal(202, b.StatusCode);
  202. Assert.NotEqual(a.Body.JobId, b.Body.JobId);
  203. }
  204. [Fact]
  205. public async Task DifferentModule_SameTenant_NotBlocked()
  206. {
  207. var store = new MemoryJobStore();
  208. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  209. var a = await svc.EnqueueAsync("S2", 9, 1, 1);
  210. var b = await svc.EnqueueAsync("S3", 9, 1, 1);
  211. Assert.Equal(202, a.StatusCode);
  212. Assert.Equal(202, b.StatusCode);
  213. }
  214. [Fact]
  215. public async Task GetById_OtherTenant_NotFound()
  216. {
  217. var store = new MemoryJobStore();
  218. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  219. var created = await svc.EnqueueAsync("S2", 9, 1, 1);
  220. var row = await store.GetByIdAsync("S2", created.Body.JobId!.Value, 10, 1);
  221. Assert.Null(row);
  222. }
  223. [Fact]
  224. public void Scope_RejectsZeroTenant()
  225. {
  226. Assert.Throws<InvalidOperationException>(() => MdpRebuildScope.Create("S2", 0, 1));
  227. Assert.Throws<InvalidOperationException>(() => MdpRebuildScope.Create("S2", 9, 0));
  228. Assert.Throws<InvalidOperationException>(() => MdpRebuildScope.Create("S2", 9, 9));
  229. Assert.Throws<InvalidOperationException>(() => MdpRebuildScope.Create("S9", 9, 1));
  230. }
  231. [Fact]
  232. public void SqlInject_AddsTenantToEveryWhere()
  233. {
  234. var sql = """
  235. SELECT 1 FROM t WHERE a=1
  236. UNION ALL
  237. SELECT 1 FROM t WHERE b=2
  238. """;
  239. var injected = MdpSqlScope.InjectTenantFactory(sql);
  240. Assert.Equal(2, injected.Split("@TenantId", StringSplitOptions.None).Length - 1);
  241. }
  242. [Fact]
  243. public void TenantOnlySqlInject_DoesNotRequireFactoryColumn()
  244. {
  245. var injected = MdpSqlScope.InjectTenantOnly("SELECT 1 FROM mdp_std_delivery_schedule ds WHERE ds.tenant_id > 0");
  246. Assert.Contains("ds.tenant_id=@TenantId", injected);
  247. Assert.DoesNotContain("factory_id", injected, StringComparison.OrdinalIgnoreCase);
  248. Assert.DoesNotContain("@FactoryId", injected);
  249. }
  250. [Fact]
  251. public void TenantOnlySqlInject_QualifiesRootAndNestedJoinScopes()
  252. {
  253. var sql = """
  254. SELECT po.id
  255. FROM mdp_std_purchase_order po
  256. LEFT JOIN (
  257. SELECT tenant_id, po_no
  258. FROM mdp_std_delivery_schedule
  259. WHERE po_no <> ''
  260. ) ds ON po.tenant_id=ds.tenant_id
  261. WHERE po.po_no <> ''
  262. """;
  263. var injected = MdpSqlScope.InjectTenantOnly(sql);
  264. Assert.Contains("FROM mdp_std_delivery_schedule\n WHERE tenant_id=@TenantId", injected);
  265. Assert.Contains("WHERE po.tenant_id=@TenantId", injected);
  266. }
  267. /// <summary>
  268. /// mdp_source_table_registry 是全局字典表,只有 (id, source_table, source_system, remark)。
  269. /// 注入器过去把语句里每个 WHERE 都改写,给它补出 r.tenant_id=@TenantId,
  270. /// S3 的 mdp_std_supplier_item 因此每轮都报 Unknown column 'r.tenant_id' 并整轮 FAILED;
  271. /// 同样的子查询在 S4 只是碰巧被旧的 240 字符窗口盖住才没炸。
  272. /// </summary>
  273. [Fact]
  274. public void SqlInject_SkipsGlobalRegistryLookup()
  275. {
  276. var sql = """
  277. INSERT INTO mdp_std_supplier_item (source_system, item_code)
  278. SELECT COALESCE((SELECT r.source_system FROM mdp_source_table_registry r WHERE r.source_table = source_table LIMIT 1), '')
  279. FROM mdp_stg_source_list
  280. WHERE source_table='srm_purchase'
  281. """;
  282. var injected = MdpSqlScope.InjectTenantFactory(sql);
  283. Assert.Contains("WHERE r.source_table = source_table", injected);
  284. Assert.DoesNotContain("r.tenant_id", injected);
  285. Assert.DoesNotContain("r.factory_id", injected);
  286. Assert.Contains("WHERE tenant_id=@TenantId", injected);
  287. }
  288. /// <summary>
  289. /// 子查询自带的租户条件不能替外层背书。旧的 240 字符窗口会把它算进来,
  290. /// 外层于是整条漏掉租户过滤 —— 跨租户串数据,且不会报错。
  291. /// </summary>
  292. [Fact]
  293. public void SqlInject_IgnoresTenantParamInNestedSubquery()
  294. {
  295. var sql = """
  296. SELECT 1 FROM dwd_ship_trans d
  297. WHERE d.biz_date = (SELECT MAX(biz_date) FROM dwd_ship_trans WHERE tenant_id=@TenantId)
  298. """;
  299. var injected = MdpSqlScope.InjectTenantFactory(sql);
  300. Assert.Contains("WHERE d.tenant_id=@TenantId", injected);
  301. }
  302. /// <summary>
  303. /// 反过来:手写的租户条件即使排在 240 字符之外,也必须被认出来,不能重复注入。
  304. /// </summary>
  305. [Fact]
  306. public void SqlInject_HonorsTenantPredicateBeyondOldWindow()
  307. {
  308. var filler = string.Join("\n", Enumerable.Range(0, 12)
  309. .Select(i => $" AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.f{i}')),'') <> ''"));
  310. var sql = $"SELECT 1 FROM mdp_std_so s\nWHERE s.order_no <> ''\n{filler}\n AND s.tenant_id=@TenantId";
  311. var injected = MdpSqlScope.InjectTenantFactory(sql);
  312. Assert.Equal(sql, injected);
  313. }
  314. [Fact]
  315. public void S3ScopeDiscovery_ReadsNormalizedFactoryColumn()
  316. {
  317. var sql = MdpRebuildScopeCatalog.GetFactScopeSql("S3");
  318. Assert.NotNull(sql);
  319. Assert.Contains("COALESCE(NULLIF(factory_id, 0), 1) AS FactoryId", sql);
  320. }
  321. [Fact]
  322. public void S3DefaultInbound_ExcludesArchivedInvMaster()
  323. {
  324. Assert.DoesNotContain(S3MdpEntityConfig.All, x =>
  325. x.EntityCode == "S3_INVENTORY" ||
  326. x.SourceTable.Equals("InvMaster", StringComparison.OrdinalIgnoreCase));
  327. }
  328. [Fact]
  329. public async Task Success_WritesHundredPercent()
  330. {
  331. var store = new MemoryJobStore();
  332. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  333. await svc.EnqueueAsync("S2", 9, 1, 1);
  334. var job = await store.ClaimNextQueuedAsync(new[] { "S2" });
  335. var handler = new FakeHandler();
  336. await svc.RunClaimedAsync(job, handler, new InMemoryModuleRebuildLock(), CancellationToken.None);
  337. var row = store.Latest("S2", 9, 1);
  338. Assert.Equal(ModuleRebuildStatus.Success, row.Status);
  339. Assert.Equal(100, row.ProgressPercent);
  340. }
  341. [Fact]
  342. public async Task AlreadyRunning_RequeuesJob()
  343. {
  344. var store = new MemoryJobStore();
  345. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  346. await svc.EnqueueAsync("S2", 9, 1, 1);
  347. var job = await store.ClaimNextQueuedAsync(new[] { "S2" });
  348. var lck = new InMemoryModuleRebuildLock();
  349. await using var held = await lck.TryAcquireAsync(MdpRebuildScope.Create("S2", 9, 1), "holder");
  350. await svc.RunClaimedAsync(job, new FakeHandler(), lck, CancellationToken.None);
  351. Assert.Equal(ModuleRebuildStatus.Queued, store.Latest("S2", 9, 1).Status);
  352. }
  353. [Fact]
  354. public async Task Enqueue_InitialQueuedZeroPercent()
  355. {
  356. var store = new MemoryJobStore();
  357. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  358. var accepted = await svc.EnqueueAsync("S4", 9, 1, 1, "AUTO");
  359. var row = store.Latest("S4", 9, 1);
  360. Assert.Equal(202, accepted.StatusCode);
  361. Assert.Equal(ModuleRebuildStatus.Queued, row.Status);
  362. Assert.Equal(ModuleRebuildStages.Queued, row.CurrentStage);
  363. Assert.Equal(0, row.ProgressPercent);
  364. Assert.Equal(0, row.StageIndex);
  365. Assert.Equal(4, row.StageTotal);
  366. Assert.Equal("AUTO", row.TriggerType);
  367. }
  368. [Fact]
  369. public async Task ConcurrentEnqueue_SameScope_OnlyOneActive()
  370. {
  371. var store = new MemoryJobStore();
  372. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  373. var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => svc.EnqueueAsync("S2", 9, 1, 1)));
  374. Assert.Equal(1, results.Count(x => x.StatusCode == 202));
  375. Assert.Equal(19, results.Count(x => x.StatusCode == 409));
  376. Assert.Single(store.Rows.Where(x => x.Status is ModuleRebuildStatus.Queued or ModuleRebuildStatus.Running));
  377. Assert.True(results.Where(x => x.StatusCode == 409).All(x => x.Body.JobId == results.First(y => y.StatusCode == 202).Body.JobId));
  378. }
  379. [Fact]
  380. public async Task DifferentFactory_NotBlocked()
  381. {
  382. var store = new MemoryJobStore();
  383. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  384. var a = await svc.EnqueueAsync("S2", 9, 1, 1);
  385. var b = await svc.EnqueueAsync("S2", 9, 2, 1);
  386. Assert.Equal(202, a.StatusCode);
  387. Assert.Equal(202, b.StatusCode);
  388. Assert.NotEqual(a.Body.JobId, b.Body.JobId);
  389. }
  390. [Fact]
  391. public async Task DisabledModule_Returns404()
  392. {
  393. var store = new MemoryJobStore();
  394. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new DisabledModuleRebuildCapability());
  395. var result = await svc.EnqueueAsync("S2", 9, 1, 1);
  396. Assert.Equal(404, result.StatusCode);
  397. Assert.Empty(store.Rows);
  398. }
  399. [Fact]
  400. public async Task Failed_KeepsStageAndPercent()
  401. {
  402. var store = new MemoryJobStore();
  403. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  404. await svc.EnqueueAsync("S2", 9, 1, 1);
  405. var job = await store.ClaimNextQueuedAsync(new[] { "S2" });
  406. await svc.RunClaimedAsync(job, new ThrowingHandler(), new InMemoryModuleRebuildLock(), CancellationToken.None);
  407. var row = store.Latest("S2", 9, 1);
  408. Assert.Equal(ModuleRebuildStatus.Failed, row.Status);
  409. Assert.Equal(ModuleRebuildStages.Kpi, row.FailedStage);
  410. Assert.Equal(65, row.ProgressPercent);
  411. Assert.NotEqual(100, row.ProgressPercent);
  412. }
  413. [Fact]
  414. public async Task Cancellation_MarksCancelled()
  415. {
  416. var store = new MemoryJobStore();
  417. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  418. await svc.EnqueueAsync("S2", 9, 1, 1);
  419. var job = await store.ClaimNextQueuedAsync(new[] { "S2" });
  420. using var cts = new CancellationTokenSource();
  421. var run = svc.RunClaimedAsync(job, new HangingHandler(), new InMemoryModuleRebuildLock(), cts.Token);
  422. await Task.Delay(200);
  423. cts.Cancel();
  424. await run;
  425. Assert.Equal(ModuleRebuildStatus.Cancelled, store.Latest("S2", 9, 1).Status);
  426. }
  427. [Fact]
  428. public async Task FailStale_ClosesRunningWithoutHeartbeat()
  429. {
  430. var store = new MemoryJobStore();
  431. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  432. await svc.EnqueueAsync("S2", 9, 1, 1);
  433. var job = store.Latest("S2", 9, 1);
  434. job.Status = ModuleRebuildStatus.Running;
  435. job.HeartbeatAt = DateTime.Now.AddHours(-1);
  436. await svc.FailStaleAsync();
  437. Assert.Equal(ModuleRebuildStatus.Failed, store.Latest("S2", 9, 1).Status);
  438. Assert.Contains("服务中断", store.Latest("S2", 9, 1).ErrorMessage);
  439. }
  440. [Fact]
  441. public async Task Progress_DoesNotRegress()
  442. {
  443. var store = new MemoryJobStore();
  444. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  445. await svc.EnqueueAsync("S2", 9, 1, 1);
  446. var job = store.Latest("S2", 9, 1);
  447. await svc.ApplyProgressAsync(job.Id, "S2", new ModuleProgressUpdate(ModuleRebuildStages.Staging, 1, 20, "stg"));
  448. await svc.ApplyProgressAsync(job.Id, "S2", new ModuleProgressUpdate(ModuleRebuildStages.Standard, 2, 40, "std"));
  449. await svc.ApplyProgressAsync(job.Id, "S2", new ModuleProgressUpdate(ModuleRebuildStages.Dwd, 3, 60, "dwd"));
  450. var percents = store.ProgressHistory.Select(x => x.Percent).ToArray();
  451. for (var i = 1; i < percents.Length; i++)
  452. Assert.True(percents[i] >= percents[i - 1], $"percent regress {percents[i - 1]} -> {percents[i]}");
  453. Assert.Equal(60, job.ProgressPercent);
  454. }
  455. [Fact]
  456. public void StageIndex_IsMonotonicPerModule()
  457. {
  458. Assert.True(ModuleRebuildStages.ToStageIndex("S2", ModuleRebuildStages.Staging)
  459. < ModuleRebuildStages.ToStageIndex("S2", ModuleRebuildStages.Standard));
  460. Assert.True(ModuleRebuildStages.ToStageIndex("S4", ModuleRebuildStages.Dwd)
  461. < ModuleRebuildStages.ToStageIndex("S4", ModuleRebuildStages.Kpi));
  462. Assert.True(ModuleRebuildStages.ToStageIndex("S5", ModuleRebuildStages.T8Inbound)
  463. < ModuleRebuildStages.ToStageIndex("S5", ModuleRebuildStages.KpiCalculating));
  464. Assert.Equal(5, ModuleRebuildStages.StageTotal("S1"));
  465. Assert.Equal(4, ModuleRebuildStages.StageTotal("S4"));
  466. Assert.Equal(4, ModuleRebuildStages.StageTotal("S7"));
  467. }
  468. [Fact]
  469. public void LockName_IsolatesTenantFactoryModule()
  470. {
  471. var a = MdpRebuildScope.Create("S2", 9, 1);
  472. var b = MdpRebuildScope.Create("S2", 10, 1);
  473. var c = MdpRebuildScope.Create("S3", 9, 1);
  474. Assert.Equal("S2_MDP_FULL:9:1", a.LockName);
  475. Assert.NotEqual(a.LockName, b.LockName);
  476. Assert.NotEqual(a.LockName, c.LockName);
  477. }
  478. [Fact]
  479. public void SqlInject_DoesNotDoubleInject()
  480. {
  481. var sql = "SELECT 1 FROM t w WHERE w.tenant_id=@TenantId AND a=1";
  482. var injected = MdpSqlScope.InjectTenantFactory(sql);
  483. Assert.Equal(1, injected.Split("@TenantId", StringSplitOptions.None).Length - 1);
  484. }
  485. [Fact]
  486. public async Task JobRunner_EnqueuesEachScope()
  487. {
  488. var store = new MemoryJobStore();
  489. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  490. var a = await svc.EnqueueAsync("S2", 9, 1, null, "BOOTSTRAP");
  491. var b = await svc.EnqueueAsync("S2", 10, 1, null, "BOOTSTRAP");
  492. Assert.Equal(202, a.StatusCode);
  493. Assert.Equal(202, b.StatusCode);
  494. Assert.Equal(2, store.Rows.Count);
  495. Assert.All(store.Rows, x => Assert.Equal("BOOTSTRAP", x.TriggerType));
  496. }
  497. private sealed class FakeHandler : IModuleRebuildHandler
  498. {
  499. public string ModuleCode => "S2";
  500. public Task<ModuleRebuildResult> RunAsync(MdpRebuildScope scope, string triggerType, long jobId, Func<ModuleProgressUpdate, Task> report, CancellationToken cancellationToken) =>
  501. Task.FromResult(new ModuleRebuildResult { BatchId = "b1", StageRows = 1 });
  502. }
  503. private sealed class ThrowingHandler : IModuleRebuildHandler
  504. {
  505. public string ModuleCode => "S2";
  506. public async Task<ModuleRebuildResult> RunAsync(MdpRebuildScope scope, string triggerType, long jobId, Func<ModuleProgressUpdate, Task> report, CancellationToken cancellationToken)
  507. {
  508. await report(new ModuleProgressUpdate(ModuleRebuildStages.Kpi, 4, 65, "正在重算 KPI"));
  509. throw new InvalidOperationException("kpi failed");
  510. }
  511. }
  512. private sealed class HangingHandler : IModuleRebuildHandler
  513. {
  514. public string ModuleCode => "S2";
  515. public async Task<ModuleRebuildResult> RunAsync(MdpRebuildScope scope, string triggerType, long jobId, Func<ModuleProgressUpdate, Task> report, CancellationToken cancellationToken)
  516. {
  517. await Task.Delay(Timeout.Infinite, cancellationToken);
  518. return new ModuleRebuildResult();
  519. }
  520. }
  521. private sealed class DisabledModuleRebuildCapability : IModuleRebuildCapability
  522. {
  523. public bool IsEnabled(string moduleCode) => false;
  524. public int MaxParallelScopes => 2;
  525. public int GlobalMaxParallelScopes => 2;
  526. public int AutoMinIntervalHours => 6;
  527. }
  528. private sealed class ConfigurableCapability : IModuleRebuildCapability
  529. {
  530. public int AutoMinIntervalHours { get; init; } = 6;
  531. public bool IsEnabled(string moduleCode) => true;
  532. public int MaxParallelScopes => 2;
  533. public int GlobalMaxParallelScopes => 2;
  534. }
  535. private sealed class MemoryJobStore : IModuleRebuildJobStore
  536. {
  537. public List<AdoModuleDashboardRebuildJob> Rows { get; } = new();
  538. public List<(long JobId, int Percent, string Stage)> ProgressHistory { get; } = new();
  539. private long _id = 1;
  540. private readonly object _gate = new();
  541. public Task<AdoModuleDashboardRebuildJob> ClaimNextQueuedAsync(IReadOnlyCollection<string> enabledModules, int globalMaxParallelScopes, bool runner, CancellationToken ct = default)
  542. => Task.FromResult<AdoModuleDashboardRebuildJob>(null!);
  543. public Task<bool> RequestCancelAsync(long jobId, CancellationToken ct = default)
  544. {
  545. lock (_gate)
  546. {
  547. var row = Rows.FirstOrDefault(x => x.Id == jobId
  548. && x.Status is ModuleRebuildStatus.Queued or ModuleRebuildStatus.Running);
  549. if (row == null) return Task.FromResult(false);
  550. row.CancelRequestedFlag = true;
  551. return Task.FromResult(true);
  552. }
  553. }
  554. public Task<bool> IsCancelRequestedAsync(long jobId, CancellationToken ct = default)
  555. {
  556. lock (_gate)
  557. return Task.FromResult(Rows.FirstOrDefault(x => x.Id == jobId)?.CancelRequestedFlag ?? false);
  558. }
  559. public Task<long> FindOpenRunLogIdAsync(string moduleCode, long tenantId, DateTime startedAfter)
  560. => Task.FromResult(0L);
  561. public Task<AdoModuleDashboardRebuildJob> FindLastSuccessAsync(string moduleCode, long tenantId, long factoryId, CancellationToken ct = default)
  562. {
  563. lock (_gate)
  564. return Task.FromResult(Rows
  565. .Where(x => x.ModuleCode == moduleCode && x.TenantId == tenantId && x.FactoryId == factoryId
  566. && x.Status == ModuleRebuildStatus.Success)
  567. .OrderByDescending(x => x.FinishedAt)
  568. .FirstOrDefault());
  569. }
  570. public AdoModuleDashboardRebuildJob Latest(string module, long tenantId, long factoryId)
  571. {
  572. lock (_gate)
  573. return Rows.Where(x => x.ModuleCode == module && x.TenantId == tenantId && x.FactoryId == factoryId).OrderByDescending(x => x.Id).First();
  574. }
  575. public Task<AdoModuleDashboardRebuildJob> InsertQueuedAsync(AdoModuleDashboardRebuildJob row, CancellationToken ct = default)
  576. {
  577. lock (_gate)
  578. {
  579. row.Id = _id++;
  580. Rows.Add(row);
  581. return Task.FromResult(row);
  582. }
  583. }
  584. public Task<AdoModuleDashboardRebuildJob> FindActiveAsync(string moduleCode, long tenantId, long factoryId, CancellationToken ct = default)
  585. {
  586. lock (_gate)
  587. return Task.FromResult(Rows.Where(x => x.ModuleCode == moduleCode && x.TenantId == tenantId && x.FactoryId == factoryId
  588. && x.Status is ModuleRebuildStatus.Queued or ModuleRebuildStatus.Running)
  589. .OrderBy(x => x.Id).FirstOrDefault());
  590. }
  591. public Task<bool> TrySupersedeQueuedAsync(long jobId, string reason, CancellationToken ct = default)
  592. {
  593. lock (_gate)
  594. {
  595. var row = Rows.FirstOrDefault(x => x.Id == jobId && x.Status == ModuleRebuildStatus.Queued);
  596. if (row == null) return Task.FromResult(false);
  597. row.Status = ModuleRebuildStatus.Cancelled;
  598. row.CurrentStage = ModuleRebuildStages.Cancelled;
  599. row.FinishedAt = DateTime.Now;
  600. row.ErrorMessage = reason;
  601. return Task.FromResult(true);
  602. }
  603. }
  604. public Task<int> CancelUnstartedByTriggerAsync(string triggerType, TimeSpan olderThan, string reason, CancellationToken ct = default)
  605. {
  606. lock (_gate)
  607. {
  608. var trigger = triggerType?.Trim().ToUpperInvariant();
  609. var cutoff = DateTime.Now - olderThan;
  610. var hits = Rows.Where(x => x.Status == ModuleRebuildStatus.Queued
  611. && string.Equals(x.TriggerType, trigger, StringComparison.Ordinal)
  612. && x.RequestedBy == null
  613. && (olderThan <= TimeSpan.Zero || x.SubmittedAt < cutoff))
  614. .ToList();
  615. foreach (var row in hits)
  616. {
  617. row.Status = ModuleRebuildStatus.Cancelled;
  618. row.CurrentStage = ModuleRebuildStages.Cancelled;
  619. row.FinishedAt = DateTime.Now;
  620. row.ErrorMessage = reason;
  621. }
  622. return Task.FromResult(hits.Count);
  623. }
  624. }
  625. public Task<int> FailStaleQueuedAsync(TimeSpan staleAfter, string reason, CancellationToken ct = default)
  626. {
  627. lock (_gate)
  628. {
  629. var cutoff = DateTime.Now - staleAfter;
  630. var hits = Rows.Where(x => x.Status == ModuleRebuildStatus.Queued && x.SubmittedAt < cutoff).ToList();
  631. foreach (var row in hits)
  632. {
  633. row.Status = ModuleRebuildStatus.Failed;
  634. row.CurrentStage = ModuleRebuildStages.Failed;
  635. row.FinishedAt = DateTime.Now;
  636. row.ErrorMessage = reason;
  637. }
  638. return Task.FromResult(hits.Count);
  639. }
  640. }
  641. public Task<AdoModuleDashboardRebuildJob> GetByIdAsync(string moduleCode, long id, long tenantId, long factoryId, CancellationToken ct = default)
  642. {
  643. lock (_gate)
  644. return Task.FromResult(Rows.FirstOrDefault(x => x.Id == id && x.ModuleCode == moduleCode && x.TenantId == tenantId && x.FactoryId == factoryId));
  645. }
  646. public Task<AdoModuleDashboardRebuildJob> GetLatestAsync(string moduleCode, long tenantId, long factoryId, CancellationToken ct = default)
  647. {
  648. lock (_gate)
  649. return Task.FromResult(Rows.Where(x => x.ModuleCode == moduleCode && x.TenantId == tenantId && x.FactoryId == factoryId)
  650. .OrderByDescending(x => x.Id).FirstOrDefault());
  651. }
  652. public Task<AdoModuleDashboardRebuildJob> ClaimNextQueuedAsync(IReadOnlyCollection<string> enabledModules, CancellationToken ct = default)
  653. {
  654. lock (_gate)
  655. {
  656. var row = Rows.Where(x => x.Status == ModuleRebuildStatus.Queued && enabledModules.Contains(x.ModuleCode))
  657. .Where(x => !Rows.Any(y => y.ModuleCode == x.ModuleCode && y.TenantId == x.TenantId && y.FactoryId == x.FactoryId && y.Status == ModuleRebuildStatus.Running))
  658. .OrderBy(x => x.Id).FirstOrDefault();
  659. if (row == null) return Task.FromResult<AdoModuleDashboardRebuildJob>(null);
  660. row.Status = ModuleRebuildStatus.Running;
  661. row.StartedAt = DateTime.Now;
  662. row.HeartbeatAt = DateTime.Now;
  663. return Task.FromResult(row);
  664. }
  665. }
  666. public Task UpdateAsync(AdoModuleDashboardRebuildJob row, CancellationToken ct = default) => Task.CompletedTask;
  667. public Task UpdateProgressAsync(long jobId, string currentStage, int stageIndex, int progressPercent, string message, DateTime now, CancellationToken ct = default)
  668. {
  669. lock (_gate)
  670. {
  671. var row = Rows.FirstOrDefault(x => x.Id == jobId);
  672. if (row == null) return Task.CompletedTask;
  673. row.CurrentStage = currentStage;
  674. row.StageIndex = stageIndex;
  675. row.ProgressPercent = progressPercent;
  676. row.ProgressMessage = message;
  677. row.LastProgressAt = now;
  678. row.HeartbeatAt = now;
  679. row.UpdateTime = now;
  680. ProgressHistory.Add((jobId, progressPercent, currentStage));
  681. return Task.CompletedTask;
  682. }
  683. }
  684. public Task UpdateStageResultAsync(long jobId, string completedStage, int rows, int progressPercent, string nextMessage, string detailJson, DateTime now, CancellationToken ct = default) =>
  685. UpdateProgressAsync(jobId, completedStage, 0, progressPercent, nextMessage, now, ct);
  686. public Task TouchHeartbeatAsync(long jobId, DateTime now, CancellationToken ct = default)
  687. {
  688. lock (_gate)
  689. {
  690. var row = Rows.FirstOrDefault(x => x.Id == jobId);
  691. if (row != null) row.HeartbeatAt = now;
  692. return Task.CompletedTask;
  693. }
  694. }
  695. public Task FailStaleRunningAsync(TimeSpan staleAfter, CancellationToken ct = default)
  696. {
  697. lock (_gate)
  698. {
  699. var cutoff = DateTime.Now - staleAfter;
  700. foreach (var row in Rows.Where(x => x.Status == ModuleRebuildStatus.Running && (x.HeartbeatAt == null || x.HeartbeatAt < cutoff)))
  701. {
  702. row.Status = ModuleRebuildStatus.Failed;
  703. row.CurrentStage = ModuleRebuildStages.Failed;
  704. row.FinishedAt = DateTime.Now;
  705. row.LastProgressAt = DateTime.Now;
  706. row.UpdateTime = DateTime.Now;
  707. row.ErrorMessage = "服务中断,任务未正常结束";
  708. }
  709. return Task.CompletedTask;
  710. }
  711. }
  712. }
  713. }