MdpTargetPushDispatcher.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. using Admin.NET.Plugin.AiDOP.DataPlatform.HotWatch;
  2. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  3. using Microsoft.Extensions.Logging;
  4. namespace Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  5. /// <summary>
  6. /// 出站分发:扫描 mdp_outbox,按 source_type 路由到 API / DB 执行器。
  7. /// WP10 S4b / D12:退避 1m/5m/15m/30m/60m/120m,MaxRetry=6;配置类失败不烧重试。
  8. /// P-029:MarkAsync 按 UpdateTime 乐观锁,避免与 TryEnqueueOrRefreshAsync 并发整列覆盖。
  9. /// </summary>
  10. public sealed class MdpTargetPushDispatcher : ITransient
  11. {
  12. public const int DefaultTake = 500;
  13. private const int MaxRetry = 6;
  14. /// <summary>D12:按 retry_count 下标取退避分钟数(clamp 到末项)。</summary>
  15. private static readonly int[] BackoffMinutes = [1, 5, 15, 30, 60, 120];
  16. private static readonly string[] PermanentErrorPrefixes =
  17. [
  18. "PAYLOAD_INVALID",
  19. "CONSTRAINT_VIOLATION",
  20. "SQL_CONSTRAINT",
  21. "UNSUPPORTED_OP",
  22. ];
  23. private readonly ISqlSugarClient _db;
  24. private readonly MdpApiPushExecutor _api;
  25. private readonly MdpDbPushExecutor _dbPush;
  26. private readonly MdpHotWatchService _hotWatch;
  27. private readonly ILogger _logger;
  28. public MdpTargetPushDispatcher(
  29. ISqlSugarClient db,
  30. MdpApiPushExecutor api,
  31. MdpDbPushExecutor dbPush,
  32. MdpHotWatchService hotWatch,
  33. ILoggerFactory loggerFactory)
  34. {
  35. _db = db;
  36. _api = api;
  37. _dbPush = dbPush;
  38. _hotWatch = hotWatch;
  39. _logger = loggerFactory.CreateLogger(nameof(MdpTargetPushDispatcher));
  40. }
  41. public async Task<(int success, int failed, int skipped)> PushPendingAsync(
  42. int take = DefaultTake, CancellationToken cancellationToken = default)
  43. {
  44. var now = DateTime.Now;
  45. var pending = await _db.Queryable<MdpOutbox>()
  46. .Where(x => x.Status == 0
  47. && x.RetryCount < MaxRetry
  48. && (x.NextRetryTime == null || x.NextRetryTime <= now))
  49. .OrderBy(x => x.Id)
  50. .Take(take > 0 ? take : DefaultTake)
  51. .ToListAsync(cancellationToken);
  52. var success = 0;
  53. var failed = 0;
  54. var skipped = 0;
  55. foreach (var item in pending)
  56. {
  57. cancellationToken.ThrowIfCancellationRequested();
  58. // 乐观锁基准:仅当 UpdateTime 未变时写入(期间被 refresh 则放弃)
  59. var loadedUpdateTime = item.UpdateTime;
  60. try
  61. {
  62. var sources = await _db.Queryable<MdpSource>()
  63. .Where(x => x.SourceCode == item.TargetSourceCode && x.Status == 1)
  64. .Take(1)
  65. .ToListAsync(cancellationToken);
  66. var source = sources.FirstOrDefault();
  67. if (source == null)
  68. {
  69. // 配置类失败:保持 pending,不烧 retry_count,便于配置修复后被 60s 兜底作业捡起
  70. await MarkConfigIssueAsync(
  71. item,
  72. "SOURCE_DISABLED",
  73. $"目标源 {item.TargetSourceCode} 未启用",
  74. cancellationToken);
  75. skipped++;
  76. continue;
  77. }
  78. if (!TryResolve(source, out var executor, out var routeError))
  79. {
  80. await MarkConfigIssueAsync(item, "SOURCE_ROUTE_UNRESOLVED", routeError!, cancellationToken);
  81. skipped++;
  82. continue;
  83. }
  84. _logger.LogInformation(
  85. "[MdpTargetPushDispatcher] outbox id={Id} source={Source} type={SourceType} via={Executor}",
  86. item.Id, source.SourceCode, source.SourceType, executor!.SupportedType);
  87. var result = await executor.PushAsync(source, item, cancellationToken);
  88. if (result.Success)
  89. {
  90. var marked = await MarkAsync(
  91. item, 1, result.ResponseJson, null, null, null, loadedUpdateTime, cancellationToken);
  92. if (!marked)
  93. {
  94. item.Status = 0;
  95. skipped++;
  96. continue;
  97. }
  98. try
  99. {
  100. await _hotWatch.TryEnrollFromOutboxSuccessAsync(item, cancellationToken);
  101. }
  102. catch (Exception enrollEx)
  103. {
  104. _logger.LogWarning(
  105. enrollEx,
  106. "[MdpTargetPushDispatcher] hot-watch enroll failed outbox id={Id} action={Action}",
  107. item.Id, item.ActionCode);
  108. }
  109. success++;
  110. }
  111. else
  112. {
  113. await ApplyFailureAsync(
  114. item, result.ResponseJson, result.ErrorMessage, loadedUpdateTime, cancellationToken);
  115. if (item.Status == 2) failed++; else skipped++;
  116. }
  117. }
  118. catch (Exception ex)
  119. {
  120. await ApplyFailureAsync(item, null, ex.Message, loadedUpdateTime, cancellationToken);
  121. if (item.Status == 2) failed++; else skipped++;
  122. _logger.LogWarning(ex, "[MdpTargetPushDispatcher] outbox id={Id} failed", item.Id);
  123. }
  124. }
  125. return (success, failed, skipped);
  126. }
  127. private async Task ApplyFailureAsync(
  128. MdpOutbox item,
  129. string? responseJson,
  130. string? errorMessage,
  131. DateTime loadedUpdateTime,
  132. CancellationToken ct)
  133. {
  134. var msg = Truncate(errorMessage, 900);
  135. var (errorCode, permanent) = ClassifyFailure(msg);
  136. if (permanent)
  137. {
  138. // 立即死信,不递增 retry_count
  139. item.NextRetryTime = null;
  140. item.LastErrorCode = errorCode;
  141. var marked = await MarkAsync(item, 2, responseJson, msg, null, errorCode, loadedUpdateTime, ct);
  142. if (!marked) item.Status = 0;
  143. return;
  144. }
  145. item.RetryCount++;
  146. item.LastErrorCode = errorCode;
  147. if (item.RetryCount >= MaxRetry)
  148. {
  149. item.NextRetryTime = null;
  150. var marked = await MarkAsync(item, 2, responseJson, msg, null, errorCode, loadedUpdateTime, ct);
  151. if (!marked) item.Status = 0;
  152. }
  153. else
  154. {
  155. item.NextRetryTime = DateTime.Now.AddMinutes(GetBackoffMinutes(item.RetryCount));
  156. var marked = await MarkAsync(
  157. item, 0, responseJson, msg, item.NextRetryTime, errorCode, loadedUpdateTime, ct);
  158. if (!marked) item.Status = 0;
  159. }
  160. }
  161. /// <summary>
  162. /// 可重试 vs 不可重试。配置类走 <see cref="MarkConfigIssueAsync"/>,不进本方法。
  163. /// </summary>
  164. private static (string? errorCode, bool permanent) ClassifyFailure(string? error)
  165. {
  166. if (string.IsNullOrWhiteSpace(error))
  167. return ("UNKNOWN", false);
  168. var trimmed = error.Trim();
  169. foreach (var prefix in PermanentErrorPrefixes)
  170. {
  171. if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
  172. return (prefix, true);
  173. }
  174. // payload 反序列化 / 解析失败 → 永久
  175. if (trimmed.Contains("payload 解析失败", StringComparison.OrdinalIgnoreCase)
  176. || trimmed.Contains("payload parse", StringComparison.OrdinalIgnoreCase)
  177. || trimmed.Contains("JsonException", StringComparison.OrdinalIgnoreCase)
  178. || trimmed.Contains("反序列化", StringComparison.OrdinalIgnoreCase))
  179. return ("PAYLOAD_INVALID", true);
  180. // SQL 约束 / 唯一冲突 → 永久
  181. if (ContainsAny(trimmed,
  182. "unique constraint", "unique key", "duplicate key", "duplicate entry",
  183. "primary key", "violation of unique", "violation of primary",
  184. "cannot insert duplicate", "约束", "唯一索引", "主键冲突"))
  185. return ("CONSTRAINT_VIOLATION", true);
  186. if (trimmed.Contains("不支持的 op", StringComparison.OrdinalIgnoreCase))
  187. return ("UNSUPPORTED_OP", true);
  188. // 连接 / 超时 → 可重试
  189. if (ContainsAny(trimmed,
  190. "timeout", "timed out", "connection", "network", "could not open",
  191. "unable to connect", "transport-level", "broken pipe", "socket",
  192. "连接", "超时", "无法连接"))
  193. return ("TRANSIENT", false);
  194. return ("RETRYABLE", false);
  195. }
  196. private static bool ContainsAny(string haystack, params string[] needles)
  197. {
  198. foreach (var n in needles)
  199. {
  200. if (haystack.Contains(n, StringComparison.OrdinalIgnoreCase))
  201. return true;
  202. }
  203. return false;
  204. }
  205. /// <summary>retry_count 从 1 起对应 Backoff[0];越界 clamp 到末项。</summary>
  206. private static int GetBackoffMinutes(int retryCount)
  207. {
  208. var idx = Math.Max(0, retryCount - 1);
  209. if (idx >= BackoffMinutes.Length)
  210. idx = BackoffMinutes.Length - 1;
  211. return BackoffMinutes[idx];
  212. }
  213. /// <summary>
  214. /// 按 source_type / 连接信息解析执行器;无法解析时返回可诊断错误(不再静默落到 API)。
  215. /// </summary>
  216. private bool TryResolve(MdpSource source, out IMdpTargetPushExecutor? executor, out string? errorMessage)
  217. {
  218. var t = (source.SourceType ?? "").Trim().ToUpperInvariant();
  219. if (t is "DB" or "SQLSERVER" or "MYSQL" or "DATABASE")
  220. {
  221. executor = _dbPush;
  222. errorMessage = null;
  223. return true;
  224. }
  225. if (t == "API" || t == "HTTP")
  226. {
  227. executor = _api;
  228. errorMessage = null;
  229. return true;
  230. }
  231. // 未标注类型时:有库连接信息则走 DB,否则不再猜 API
  232. if (!string.IsNullOrWhiteSpace(source.DbHost) && !string.IsNullOrWhiteSpace(source.DbName))
  233. {
  234. executor = _dbPush;
  235. errorMessage = null;
  236. return true;
  237. }
  238. _logger.LogWarning(
  239. "[MdpTargetPushDispatcher] 路由未解析 source={Code} source_type='{Type}' DbHost='{Host}' DbName='{Name}' ApiBaseUrl='{Api}'",
  240. source.SourceCode, source.SourceType, source.DbHost, source.DbName, source.ApiBaseUrl);
  241. executor = null;
  242. errorMessage =
  243. $"SOURCE_ROUTE_UNRESOLVED: 源 {source.SourceCode} 既未标注 source_type,也无 DbHost/DbName";
  244. return false;
  245. }
  246. /// <summary>配置类问题:仅写 ErrorMsg/LastErrorCode/UpdateTime,status 保持 0,retry_count 不变。</summary>
  247. private async Task MarkConfigIssueAsync(MdpOutbox item, string errorCode, string error, CancellationToken ct)
  248. {
  249. item.ErrorMsg = Truncate(error, 900);
  250. item.LastErrorCode = Truncate(errorCode, 64);
  251. item.UpdateTime = DateTime.Now;
  252. await _db.Updateable(item)
  253. .UpdateColumns(x => new { x.ErrorMsg, x.LastErrorCode, x.UpdateTime })
  254. .ExecuteCommandAsync(ct);
  255. }
  256. /// <summary>
  257. /// 条件更新:仅当 UpdateTime 与加载时一致才写入,避免与入队 refresh 丢失更新。
  258. /// </summary>
  259. private async Task<bool> MarkAsync(
  260. MdpOutbox item,
  261. int status,
  262. string? responseJson,
  263. string? error,
  264. DateTime? nextRetryTime,
  265. string? lastErrorCode,
  266. DateTime loadedUpdateTime,
  267. CancellationToken ct)
  268. {
  269. var now = DateTime.Now;
  270. var resp = Truncate(responseJson, 4000);
  271. var code = Truncate(lastErrorCode, 64);
  272. var affected = await _db.Updateable<MdpOutbox>()
  273. .SetColumns(x => x.Status == status)
  274. .SetColumns(x => x.RetryCount == item.RetryCount)
  275. .SetColumns(x => x.ResponseJson == resp)
  276. .SetColumns(x => x.ErrorMsg == error)
  277. .SetColumns(x => x.NextRetryTime == nextRetryTime)
  278. .SetColumns(x => x.LastErrorCode == code)
  279. .SetColumns(x => x.UpdateTime == now)
  280. .Where(x => x.Id == item.Id && x.UpdateTime == loadedUpdateTime)
  281. .ExecuteCommandAsync(ct);
  282. if (affected <= 0)
  283. {
  284. _logger.LogInformation(
  285. "[MdpTargetPushDispatcher] 标记被跳过(期间已被重新入队)outbox id={Id} idem={Idem} wantStatus={Status}",
  286. item.Id, item.IdemKey, status);
  287. return false;
  288. }
  289. item.Status = status;
  290. item.ResponseJson = resp;
  291. item.ErrorMsg = error;
  292. item.NextRetryTime = nextRetryTime;
  293. item.LastErrorCode = code;
  294. item.UpdateTime = now;
  295. return true;
  296. }
  297. private static string? Truncate(string? s, int max) =>
  298. string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]);
  299. }