MdpTargetPushDispatcher.cs 11 KB

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