S0DimMaterializer.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  2. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  3. using Microsoft.Extensions.Logging;
  4. using SqlSugar;
  5. namespace Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
  6. /// <summary>
  7. /// 单个 S0 维度的物化:source → staging → dim,FULL REPLACE 语义。
  8. ///
  9. /// 时序与事务边界:
  10. /// <code>
  11. /// [0] 前置校验(只读):mdp_entity 配置与 definition 一致 + 源侧业务键无重复 + 数据量未超单页
  12. /// [1] source_count(只读,按租户)
  13. /// [1.5] 写前校验和闸门(只读):source 与 dim 的 (count, checksum) 一致 → SKIPPED_NO_CHANGE 直接返回,
  14. /// **不执行**下面任何一步;不一致则原样放行
  15. /// ── 阶段 I:staging(非事务,可重跑)
  16. /// [2] purge 本租户+本源系统+本源表 分区
  17. /// [3] FULL pull(源侧/写入侧双重租户过滤)
  18. /// [4] staging_count(本批四段谓词)
  19. /// ── 阶段 II:闸门
  20. /// [5] stg_count != source_count → 本维度 FAILED,**dim 不动**(保持上一轮完整快照)
  21. /// ── 阶段 III:dim(单事务)
  22. /// [6] DELETE tenant 行 + INSERT(无 ON DUPLICATE KEY)+ 事务内阻断级对账 → 任一失败整体回滚
  23. /// </code>
  24. ///
  25. /// 失败时 dim 永远停在**上一轮的完整快照**,不会半新半旧。
  26. /// </summary>
  27. public sealed class S0DimMaterializer : ITransient
  28. {
  29. private readonly ISqlSugarClient _db;
  30. private readonly MdpSourcePullDispatcher _pullDispatcher;
  31. private readonly S0DimStagingPurge _purge;
  32. private readonly S0DimSameDbStagingLoader _sameDbLoader;
  33. private readonly S0DimReconciler _reconciler;
  34. private readonly ILogger<S0DimMaterializer> _logger;
  35. /// <summary>构造。</summary>
  36. public S0DimMaterializer(
  37. ISqlSugarClient db,
  38. MdpSourcePullDispatcher pullDispatcher,
  39. S0DimStagingPurge purge,
  40. S0DimSameDbStagingLoader sameDbLoader,
  41. S0DimReconciler reconciler,
  42. ILogger<S0DimMaterializer> logger)
  43. {
  44. _db = db;
  45. _pullDispatcher = pullDispatcher;
  46. _purge = purge;
  47. _sameDbLoader = sameDbLoader;
  48. _reconciler = reconciler;
  49. _logger = logger;
  50. }
  51. /// <summary>物化一个维度。</summary>
  52. public async Task<S0DimMaterializeResult> RunAsync(
  53. S0DimDefinition def, long tenantId, string batchId, CancellationToken ct = default)
  54. {
  55. def.Validate();
  56. if (tenantId <= 0) throw new InvalidOperationException($"[{def.Key}] 拒绝无效租户:{tenantId}");
  57. if (string.IsNullOrWhiteSpace(batchId)) throw new InvalidOperationException($"[{def.Key}] batchId 不得为空");
  58. var result = new S0DimMaterializeResult { Key = def.Key, TenantId = tenantId, BatchId = batchId };
  59. var ps = new List<SugarParameter>
  60. {
  61. new("@TenantId", tenantId),
  62. new("@SourceSystem", def.SourceSystem),
  63. new("@SourceTable", def.SourceTable),
  64. new("@BatchId", batchId)
  65. };
  66. // 🔴 全局 CommandTimeOut = 30s(Admin.NET.Core/SqlSugar/SqlSugarSetup.cs:155),对本管线不够用。
  67. // 实测(mdp_transform_run_log,2026-09-08):ITEM 单维度平均 132.7s、最大 1169.8s;
  68. // ITEM_BOM 的同库 INSERT ... SELECT 单条就要 ~27s,**紧贴 30s 线**,已实际炸过三次:
  69. // 45747 ITEM "The Command Timeout expired before the operation completed."
  70. // 45941 ITEM_BOM "Connection must be Open; current state is Closed"
  71. // 45945 ITEM_BOM "The Command Timeout expired before the operation completed."
  72. // 手工逐个刷时人会重试,所以问题被掩盖;自动刷新上线后会变成每拍稳定失败。
  73. // 这里只在物化区间内临时放宽,Dispose 时还原(异常路径同样还原),
  74. // 不改全局常量、不影响其它模块 —— 范式取自
  75. // MaterialWarehouse/InventoryMdpSyncService.cs:627-644。
  76. using var commandTimeout = new LongCommandTimeoutScope(_db, MaterializeCommandTimeoutSeconds);
  77. try
  78. {
  79. // [0] 前置校验:配置漂移与源侧数据质量,全部在动任何数据之前
  80. var batchSize = await AssertEntityContractAsync(def, ct);
  81. var sourceCount = await _db.Ado.GetIntAsync(S0DimSqlBuilder.BuildSourceCountSql(def), ps);
  82. await AssertNoSourceDuplicateAsync(def, tenantId, sourceCount, batchSize, ps);
  83. // [1.5] 写前校验和闸门(**纯只读**,在任何 DELETE / INSERT 之前)
  84. //
  85. // 判据:source 与 dim 的 (count, checksum) 完全一致 → 本轮什么都不必做。
  86. //
  87. // 正确性来自下面 [6] 自己:dim 事务内的 _reconciler.AssertBlockingAsync 断言了
  88. // source checksum == dim checksum,不等即抛异常回滚。
  89. // ⇒ 任何一轮 SUCCESS 之后两者必然相等;
  90. // ⇒ 下一轮开跑时若**仍然**相等,说明源侧自上轮以来没有任何会影响 dim 的变化,
  91. // 整轮 purge → pull → FULL REPLACE 是纯无用功(还要白白重写整张租户分区)。
  92. // 不相等则原样放行,[2]..[6] 逐字不变 —— 闸门只减少「无变化时的重复劳动」,
  93. // 不改变任何一次真实刷新的语义。
  94. //
  95. // 口径与写后对账**同源**:两边都只走 S0DimReconciler.ReadChecksumPairAsync,
  96. // 不在这里另起一套算法(否则上面那条推理会在某次单边修改后静默失效)。
  97. // count 与 checksum 必须同时相等,见 S0DimChecksumPair.IsMatchOf 的注释。
  98. //
  99. // 注意闸门的位置:它在 [0] 前置校验**之后** —— 配置漂移 / 源侧业务键重复
  100. // 这些质量门禁照常每轮都跑,不因「数据没变」而被跳过。
  101. var checksum = await _reconciler.ReadChecksumPairAsync(def, tenantId, ct);
  102. result.Checksum = checksum;
  103. if (checksum.IsMatch)
  104. {
  105. result.SkipReason = S0DimMaterializeResult.SkipReasonNoChange;
  106. // StagingPurged / SourcePulled / StagingWritten 保持 0:本轮确实一行都没动。
  107. // DimRows **不填 0** —— 填 0 会被读成「dim 被清空了」。填 dim 当前真实行数。
  108. result.DimRows = checksum.DimCount;
  109. result.Status = S0DimMaterializeResult.StatusSkippedNoChange;
  110. _logger.LogInformation(
  111. "[S0Dim] {Key} tenant={Tenant} batch={Batch} 跳过:source 与 dim 校验和一致" +
  112. "(count={Cnt} chk={Chk}),staging / dim 均未触碰",
  113. def.Key, tenantId, batchId, checksum.DimCount, checksum.DimChecksum);
  114. return result;
  115. }
  116. // [2] purge:三段谓词精确到本租户 + 本源系统 + 本源表
  117. result.StagingPurged = await _purge.PurgeAsync(def, tenantId, ct);
  118. // [3] FULL pull
  119. // 租户三重防护:① MdpDbPullExecutor 对含租户列的源表加 WHERE tenant_id=@scopeTenantId;
  120. // ② MdpStagingWriter 对 tenantValue != ctx.TenantId 的行直接跳过;
  121. // ③ RequireMatchingSourceTenant=true → 源行无租户时不拿 ctx 兜底,直接跳过(绝不落 0)
  122. var pullCtx = new MdpPullContext
  123. {
  124. TenantId = tenantId,
  125. BatchId = batchId,
  126. FullRefresh = true,
  127. RequireMatchingSourceTenant = true
  128. };
  129. // 同库时优先走集合式装载(一条 INSERT ... SELECT,DB 往返 O(1));
  130. // 判定不成立或计划生成失败一律回退下面的逐行路径,语义不变、只是慢。
  131. // 参见 S0DimSameDbStagingLoader:判定只依据源与库的客观属性,
  132. // 不看 entity 名 / source_code 字面量 / 主机硬编码。
  133. var samedb = await _sameDbLoader.TryPlanAsync(def, ct);
  134. if (samedb is not null)
  135. {
  136. var written = await _sameDbLoader.LoadAsync(def, samedb, tenantId, batchId, ct);
  137. // 集合式语句无「读了多少行」的独立计数,装载行数即贴源行数;
  138. // 真正的把关在下面 [4][5] 的 stg == source 闸门,不依赖这里的自报数。
  139. result.SourcePulled = written;
  140. result.StagingWritten = written;
  141. _logger.LogInformation("[S0Dim] {Key} 走同库快路径:{Reason},写入 {Rows} 行",
  142. def.Key, samedb.Reason, written);
  143. }
  144. else
  145. {
  146. var pull = await _pullDispatcher.PullAllByEntityCodeAsync(def.EntityCode, pullCtx, ct);
  147. result.SourcePulled = pull.RowsPulled;
  148. result.StagingWritten = pull.RowsWritten;
  149. }
  150. // [4][5] 闸门:本批 staging 必须与源侧行数完全一致,否则不碰 dim
  151. var stagingCount = await _db.Ado.GetIntAsync(S0DimSqlBuilder.BuildStagingCountSql(def), ps);
  152. if (stagingCount != sourceCount)
  153. throw new InvalidOperationException(
  154. $"[{def.Key}] 贴源闸门未通过:source_count={sourceCount} 本批 stg_count={stagingCount}(dim 未改动)");
  155. // [6] dim 单事务:DELETE 本租户 → INSERT → 事务内阻断级对账
  156. result.DimRows = await MdpStdFullReplace.ReplaceAsync(
  157. _db, def.DimTable, tenantId, extraWhere: null,
  158. insertScopedAsync: async () =>
  159. {
  160. var now = DateTime.Now;
  161. var insertPs = new List<SugarParameter>(ps) { new("@Now", now) };
  162. var rows = await _db.Ado.ExecuteCommandAsync(S0DimSqlBuilder.BuildInsertSql(def), insertPs);
  163. result.Reconcile = await _reconciler.AssertBlockingAsync(def, tenantId, batchId, ct);
  164. return rows;
  165. },
  166. ct);
  167. result.Status = S0DimMaterializeResult.StatusSuccess;
  168. _logger.LogInformation(
  169. "[S0Dim] {Key} tenant={Tenant} batch={Batch} purged={Purged} pulled={Pulled} stg={Stg} dim={Dim}",
  170. def.Key, tenantId, batchId, result.StagingPurged, result.SourcePulled, stagingCount, result.DimRows);
  171. }
  172. catch (OperationCanceledException)
  173. {
  174. // 取消不是本维度的「失败」:吞掉它会把客户端断开记成 FAILED,
  175. // 且外层 RefreshAsync 的 ct.ThrowIfCancellationRequested() 会在下一轮抛出,
  176. // 导致 CompleteRunLogAsync 永不执行、run log 永久停在 RUNNING。
  177. // 事务侧无需担心:异常已在 MdpStdFullReplace 的 catch 中回滚,dim 未改动。
  178. throw;
  179. }
  180. catch (Exception ex)
  181. {
  182. result.Status = S0DimMaterializeResult.StatusFailed;
  183. result.Error = ex.Message;
  184. _logger.LogError(ex, "[S0Dim] {Key} tenant={Tenant} batch={Batch} 物化失败(dim 未改动)",
  185. def.Key, tenantId, batchId);
  186. }
  187. return result;
  188. }
  189. /// <summary>
  190. /// 物化区间的命令超时(秒)。取 900 与 <c>InventoryMdpSyncService</c> 一致:
  191. /// 覆盖实测最慢的 ITEM(单维度最大 1169.8s 是整轮耗时,其中单条命令远小于此)并留足余量。
  192. /// </summary>
  193. private const int MaterializeCommandTimeoutSeconds = 900;
  194. /// <summary>物化区间内临时放宽命令超时,Dispose 时还原原值(异常路径同样还原)。</summary>
  195. private sealed class LongCommandTimeoutScope : IDisposable
  196. {
  197. private readonly ISqlSugarClient _db;
  198. private readonly int _original;
  199. public LongCommandTimeoutScope(ISqlSugarClient db, int seconds)
  200. {
  201. _db = db;
  202. _original = db.Ado.CommandTimeOut;
  203. db.Ado.CommandTimeOut = seconds;
  204. }
  205. public void Dispose() => _db.Ado.CommandTimeOut = _original;
  206. }
  207. /// <summary>
  208. /// 断言 <c>mdp_entity</c> 的运行时配置与 definition 完全一致,返回 batch_size。
  209. /// 这一步把「配置漂移」变成显式失败 —— 否则 <c>PullAll</c> 可能悄悄拉到别的表 / 别的源。
  210. /// </summary>
  211. private async Task<int> AssertEntityContractAsync(S0DimDefinition def, CancellationToken ct)
  212. {
  213. var entity = await _db.Queryable<MdpEntity>()
  214. .Where(x => x.EntityCode == def.EntityCode)
  215. .FirstAsync(ct)
  216. ?? throw new InvalidOperationException($"[{def.Key}] mdp_entity 未登记:{def.EntityCode}(migration 未执行?)");
  217. if (entity.Status != 1)
  218. throw new InvalidOperationException($"[{def.Key}] mdp_entity.{def.EntityCode} 已停用(status={entity.Status})");
  219. var source = await _db.Queryable<MdpSource>().Where(x => x.Id == entity.SourceId).FirstAsync(ct)
  220. ?? throw new InvalidOperationException($"[{def.Key}] mdp_source id={entity.SourceId} 不存在");
  221. void Expect(string what, string? actual, string expected)
  222. {
  223. if (!string.Equals(actual, expected, StringComparison.Ordinal))
  224. throw new InvalidOperationException($"[{def.Key}] mdp_entity 配置漂移:{what} 实际='{actual}' 期望='{expected}'");
  225. }
  226. Expect("source_code", source.SourceCode, def.SourceSystem);
  227. Expect("source_table_name", entity.SourceTableName, def.SourceTable);
  228. Expect("target_table_name", entity.TargetTableName, def.StagingTable);
  229. Expect("biz_key_expr", entity.BizKeyExpr, string.Join(",", def.SourceBizKeyColumns));
  230. // ── 执行器选路的三个开关:不查会被静默改道 ──
  231. // MdpSourcePullDispatcher 按
  232. // !IsNullOrWhiteSpace(entity.SourceApiPath) || source.SourceType == "API" → _apiExecutor
  233. // source.SourceType == "FILE_EXCEL" → Excel 分支
  234. // 选择执行器。上面那几条 Expect 全部**照样通过**,但拉取已不再是「本库 SELECT」。
  235. // 比较口径与 dispatcher 一致(OrdinalIgnoreCase),否则大小写差异会造成假通过。
  236. if (!string.IsNullOrWhiteSpace(entity.SourceApiPath))
  237. throw new InvalidOperationException(
  238. $"[{def.Key}] mdp_entity.source_api_path 必须为空,实际='{entity.SourceApiPath}':" +
  239. "非空会让 MdpSourcePullDispatcher 改走 API 执行器,绕开本库 DB 拉取契约");
  240. if (!string.Equals(source.SourceType, "DB", StringComparison.OrdinalIgnoreCase))
  241. throw new InvalidOperationException(
  242. $"[{def.Key}] mdp_source.source_type 必须为 DB,实际='{source.SourceType}'");
  243. if (!string.Equals(source.DbType, "MySQL", StringComparison.OrdinalIgnoreCase))
  244. throw new InvalidOperationException(
  245. $"[{def.Key}] mdp_source.db_type 必须为 MySQL,实际='{source.DbType}'");
  246. // 本阶段只做 FULL:留了 incr_column 会让 BuildSelectSql 生成 "col > @cursor",
  247. // 使源侧水位为 NULL 的行(如 DepartmentMaster 的 UATDEMO/未分配)永久不可达
  248. if (!string.Equals(entity.SyncMode, "FULL", StringComparison.OrdinalIgnoreCase))
  249. throw new InvalidOperationException($"[{def.Key}] sync_mode 必须为 FULL,实际='{entity.SyncMode}'");
  250. if (!string.IsNullOrWhiteSpace(entity.IncrColumn))
  251. throw new InvalidOperationException($"[{def.Key}] incr_column 必须为空(FULL 语义),实际='{entity.IncrColumn}'");
  252. return entity.BatchSize > 0 ? entity.BatchSize : 1000;
  253. }
  254. /// <summary>
  255. /// 源侧业务键重复探针 + 单页容量检查。两者都在 purge 之前,失败时**任何数据都未被触碰**。
  256. /// </summary>
  257. private async Task AssertNoSourceDuplicateAsync(
  258. S0DimDefinition def, long tenantId, int sourceCount, int batchSize, List<SugarParameter> ps)
  259. {
  260. // 分页依赖 MdpDbPullExecutor 无 incr_column 时的 "ORDER BY 1"(按第 1 个物理列),
  261. // 而 LocationMaster 的第 1 列是 Capacity(非唯一)→ 一旦真的翻页,OFFSET 结果不稳定。
  262. // 因此要求单页装得下;超出时显式失败并提示调大 mdp_entity.batch_size。
  263. if (sourceCount >= batchSize)
  264. throw new InvalidOperationException(
  265. $"[{def.Key}] 源行数 {sourceCount} 已达单页容量 {batchSize}:" +
  266. "通用执行器在无 incr_column 时按 'ORDER BY 1' 分页,对本表非稳定序,请调大 mdp_entity.batch_size");
  267. var q = await _db.Ado.SqlQueryAsync<SourceBizKeyProbe>(
  268. S0DimSqlBuilder.BuildBizKeyQualitySql(S0DimSqlBuilder.SourceBizKeySetSql(def)), ps);
  269. var probe = q.FirstOrDefault();
  270. if (probe is null) return;
  271. if (probe.Blank_Cnt > 0)
  272. throw new InvalidOperationException(
  273. $"[{def.Key}] tenant={tenantId} 源侧有 {probe.Blank_Cnt} 行业务键为空,拒绝物化");
  274. if (probe.Total != probe.Distinct_Cnt)
  275. throw new InvalidOperationException(
  276. $"[{def.Key}] tenant={tenantId} 源侧业务键重复(total={probe.Total} distinct={probe.Distinct_Cnt}):" +
  277. "主数据唯一性已被破坏,transform 显式失败 —— 不做 first-wins / last-wins 静默取舍");
  278. }
  279. private sealed class SourceBizKeyProbe
  280. {
  281. public int Total { get; set; }
  282. public int Distinct_Cnt { get; set; }
  283. public int Blank_Cnt { get; set; }
  284. }
  285. }