| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282 |
- using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
- using Microsoft.Extensions.Logging;
- namespace Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
- /// <summary>
- /// 出站分发:扫描 mdp_outbox,按 source_type 路由到 API / DB 执行器。
- /// WP10 S4b / D12:退避 1m/5m/15m/30m/60m/120m,MaxRetry=6;配置类失败不烧重试。
- /// </summary>
- public sealed class MdpTargetPushDispatcher : ITransient
- {
- public const int DefaultTake = 500;
- private const int MaxRetry = 6;
- /// <summary>D12:按 retry_count 下标取退避分钟数(clamp 到末项)。</summary>
- private static readonly int[] BackoffMinutes = [1, 5, 15, 30, 60, 120];
- private static readonly string[] PermanentErrorPrefixes =
- [
- "PAYLOAD_INVALID",
- "CONSTRAINT_VIOLATION",
- "SQL_CONSTRAINT",
- "UNSUPPORTED_OP",
- ];
- private readonly ISqlSugarClient _db;
- private readonly MdpApiPushExecutor _api;
- private readonly MdpDbPushExecutor _dbPush;
- private readonly ILogger _logger;
- public MdpTargetPushDispatcher(
- ISqlSugarClient db,
- MdpApiPushExecutor api,
- MdpDbPushExecutor dbPush,
- ILoggerFactory loggerFactory)
- {
- _db = db;
- _api = api;
- _dbPush = dbPush;
- _logger = loggerFactory.CreateLogger(nameof(MdpTargetPushDispatcher));
- }
- public async Task<(int success, int failed, int skipped)> PushPendingAsync(
- int take = DefaultTake, CancellationToken cancellationToken = default)
- {
- var now = DateTime.Now;
- var pending = await _db.Queryable<MdpOutbox>()
- .Where(x => x.Status == 0
- && x.RetryCount < MaxRetry
- && (x.NextRetryTime == null || x.NextRetryTime <= now))
- .OrderBy(x => x.Id)
- .Take(take > 0 ? take : DefaultTake)
- .ToListAsync(cancellationToken);
- var success = 0;
- var failed = 0;
- var skipped = 0;
- foreach (var item in pending)
- {
- cancellationToken.ThrowIfCancellationRequested();
- try
- {
- var sources = await _db.Queryable<MdpSource>()
- .Where(x => x.SourceCode == item.TargetSourceCode && x.Status == 1)
- .Take(1)
- .ToListAsync(cancellationToken);
- var source = sources.FirstOrDefault();
- if (source == null)
- {
- // 配置类失败:保持 pending,不烧 retry_count,便于配置修复后被 60s 兜底作业捡起
- await MarkConfigIssueAsync(
- item,
- "SOURCE_DISABLED",
- $"目标源 {item.TargetSourceCode} 未启用",
- cancellationToken);
- skipped++;
- continue;
- }
- if (!TryResolve(source, out var executor, out var routeError))
- {
- await MarkConfigIssueAsync(item, "SOURCE_ROUTE_UNRESOLVED", routeError!, cancellationToken);
- skipped++;
- continue;
- }
- _logger.LogInformation(
- "[MdpTargetPushDispatcher] outbox id={Id} source={Source} type={SourceType} via={Executor}",
- item.Id, source.SourceCode, source.SourceType, executor!.SupportedType);
- var result = await executor.PushAsync(source, item, cancellationToken);
- if (result.Success)
- {
- await MarkAsync(item, 1, result.ResponseJson, null, null, null, cancellationToken);
- success++;
- }
- else
- {
- await ApplyFailureAsync(item, result.ResponseJson, result.ErrorMessage, cancellationToken);
- if (item.Status == 2) failed++; else skipped++;
- }
- }
- catch (Exception ex)
- {
- await ApplyFailureAsync(item, null, ex.Message, cancellationToken);
- if (item.Status == 2) failed++; else skipped++;
- _logger.LogWarning(ex, "[MdpTargetPushDispatcher] outbox id={Id} failed", item.Id);
- }
- }
- return (success, failed, skipped);
- }
- private async Task ApplyFailureAsync(
- MdpOutbox item, string? responseJson, string? errorMessage, CancellationToken ct)
- {
- var msg = Truncate(errorMessage, 900);
- var (errorCode, permanent) = ClassifyFailure(msg);
- if (permanent)
- {
- // 立即死信,不递增 retry_count
- item.NextRetryTime = null;
- item.LastErrorCode = errorCode;
- await MarkAsync(item, 2, responseJson, msg, null, errorCode, ct);
- return;
- }
- item.RetryCount++;
- item.LastErrorCode = errorCode;
- if (item.RetryCount >= MaxRetry)
- {
- item.NextRetryTime = null;
- await MarkAsync(item, 2, responseJson, msg, null, errorCode, ct);
- }
- else
- {
- item.NextRetryTime = DateTime.Now.AddMinutes(GetBackoffMinutes(item.RetryCount));
- await MarkAsync(item, 0, responseJson, msg, item.NextRetryTime, errorCode, ct);
- }
- }
- /// <summary>
- /// 可重试 vs 不可重试。配置类走 <see cref="MarkConfigIssueAsync"/>,不进本方法。
- /// </summary>
- private static (string? errorCode, bool permanent) ClassifyFailure(string? error)
- {
- if (string.IsNullOrWhiteSpace(error))
- return ("UNKNOWN", false);
- var trimmed = error.Trim();
- foreach (var prefix in PermanentErrorPrefixes)
- {
- if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
- return (prefix, true);
- }
- // payload 反序列化 / 解析失败 → 永久
- if (trimmed.Contains("payload 解析失败", StringComparison.OrdinalIgnoreCase)
- || trimmed.Contains("payload parse", StringComparison.OrdinalIgnoreCase)
- || trimmed.Contains("JsonException", StringComparison.OrdinalIgnoreCase)
- || trimmed.Contains("反序列化", StringComparison.OrdinalIgnoreCase))
- return ("PAYLOAD_INVALID", true);
- // SQL 约束 / 唯一冲突 → 永久
- if (ContainsAny(trimmed,
- "unique constraint", "unique key", "duplicate key", "duplicate entry",
- "primary key", "violation of unique", "violation of primary",
- "cannot insert duplicate", "约束", "唯一索引", "主键冲突"))
- return ("CONSTRAINT_VIOLATION", true);
- if (trimmed.Contains("不支持的 op", StringComparison.OrdinalIgnoreCase))
- return ("UNSUPPORTED_OP", true);
- // 连接 / 超时 → 可重试
- if (ContainsAny(trimmed,
- "timeout", "timed out", "connection", "network", "could not open",
- "unable to connect", "transport-level", "broken pipe", "socket",
- "连接", "超时", "无法连接"))
- return ("TRANSIENT", false);
- return ("RETRYABLE", false);
- }
- private static bool ContainsAny(string haystack, params string[] needles)
- {
- foreach (var n in needles)
- {
- if (haystack.Contains(n, StringComparison.OrdinalIgnoreCase))
- return true;
- }
- return false;
- }
- /// <summary>retry_count 从 1 起对应 Backoff[0];越界 clamp 到末项。</summary>
- private static int GetBackoffMinutes(int retryCount)
- {
- var idx = Math.Max(0, retryCount - 1);
- if (idx >= BackoffMinutes.Length)
- idx = BackoffMinutes.Length - 1;
- return BackoffMinutes[idx];
- }
- /// <summary>
- /// 按 source_type / 连接信息解析执行器;无法解析时返回可诊断错误(不再静默落到 API)。
- /// </summary>
- private bool TryResolve(MdpSource source, out IMdpTargetPushExecutor? executor, out string? errorMessage)
- {
- var t = (source.SourceType ?? "").Trim().ToUpperInvariant();
- if (t is "DB" or "SQLSERVER" or "MYSQL" or "DATABASE")
- {
- executor = _dbPush;
- errorMessage = null;
- return true;
- }
- if (t == "API" || t == "HTTP")
- {
- executor = _api;
- errorMessage = null;
- return true;
- }
- // 未标注类型时:有库连接信息则走 DB,否则不再猜 API
- if (!string.IsNullOrWhiteSpace(source.DbHost) && !string.IsNullOrWhiteSpace(source.DbName))
- {
- executor = _dbPush;
- errorMessage = null;
- return true;
- }
- _logger.LogWarning(
- "[MdpTargetPushDispatcher] 路由未解析 source={Code} source_type='{Type}' DbHost='{Host}' DbName='{Name}' ApiBaseUrl='{Api}'",
- source.SourceCode, source.SourceType, source.DbHost, source.DbName, source.ApiBaseUrl);
- executor = null;
- errorMessage =
- $"SOURCE_ROUTE_UNRESOLVED: 源 {source.SourceCode} 既未标注 source_type,也无 DbHost/DbName";
- return false;
- }
- /// <summary>配置类问题:仅写 ErrorMsg/LastErrorCode/UpdateTime,status 保持 0,retry_count 不变。</summary>
- private async Task MarkConfigIssueAsync(MdpOutbox item, string errorCode, string error, CancellationToken ct)
- {
- item.ErrorMsg = Truncate(error, 900);
- item.LastErrorCode = Truncate(errorCode, 64);
- item.UpdateTime = DateTime.Now;
- await _db.Updateable(item)
- .UpdateColumns(x => new { x.ErrorMsg, x.LastErrorCode, x.UpdateTime })
- .ExecuteCommandAsync(ct);
- }
- private async Task MarkAsync(
- MdpOutbox item,
- int status,
- string? responseJson,
- string? error,
- DateTime? nextRetryTime,
- string? lastErrorCode,
- CancellationToken ct)
- {
- item.Status = status;
- item.ResponseJson = Truncate(responseJson, 4000);
- item.ErrorMsg = error;
- item.NextRetryTime = nextRetryTime;
- item.LastErrorCode = Truncate(lastErrorCode, 64);
- item.UpdateTime = DateTime.Now;
- await _db.Updateable(item)
- .UpdateColumns(x => new
- {
- x.Status,
- x.RetryCount,
- x.ResponseJson,
- x.ErrorMsg,
- x.NextRetryTime,
- x.LastErrorCode,
- x.UpdateTime
- })
- .ExecuteCommandAsync(ct);
- }
- private static string? Truncate(string? s, int max) =>
- string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]);
- }
|