S8AuthorityHealthEvaluator.cs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.Health;
  2. /// <summary>快照发布不变量的观测结果。</summary>
  3. public static class S8SnapshotInvariant
  4. {
  5. /// <summary>该 Authority 是当前态镜像,无发布步骤 ⇒ 不适用。</summary>
  6. public const string NotApplicable = "NOT_APPLICABLE";
  7. /// <summary>恰好一个当前批次,且等于最近一次成功运行的批次;当前快照内无必填列为空。</summary>
  8. public const string Satisfied = "SATISFIED";
  9. /// <summary>零个当前批次 —— 合法空快照与发布失败在库里字面等同,无法区分。</summary>
  10. public const string Unconfirmed = "UNCONFIRMED";
  11. /// <summary>多个当前批次,或当前批次与最近成功运行不符,或当前快照内存在必填列为空。</summary>
  12. public const string Violated = "VIOLATED";
  13. }
  14. /// <summary>健康判定所需的全部观测值。由适配层采集,判定本身不碰数据库。</summary>
  15. public sealed class S8AuthorityObservation
  16. {
  17. public string DatasetCode { get; init; } = string.Empty;
  18. public long TenantId { get; init; }
  19. public DateTime ObservedAt { get; init; }
  20. /// <summary>数据集是否声明了 AuthoritySpec。</summary>
  21. public bool SpecFound { get; init; }
  22. /// <summary>见 <see cref="S8AuthoritySpec.ProducerTrusted"/>。</summary>
  23. public bool ProducerTrusted { get; init; } = true;
  24. public string UntrustedReasonCode { get; init; } = S8AuthorityHealthReason.Rule01MultiWriterUntrusted;
  25. /// <summary>回溯窗口内最近一次成功生产运行的<b>结束</b>时刻。null = 窗口内没有成功。</summary>
  26. public DateTime? LastSuccessEndAt { get; init; }
  27. /// <summary>最近一次成功之后是否出现了终态非成功的运行。</summary>
  28. public bool HasNewerTerminalFailure { get; init; }
  29. /// <summary>上一条的具体状态字面量(用于区分 LATEST_FAILED / LATEST_PARTIAL)。</summary>
  30. public string? NewerTerminalFailureStatus { get; init; }
  31. /// <summary>比最近一次成功更新、且尚未终态的运行中,最早那条的开始时刻。null = 没有在途运行。</summary>
  32. public DateTime? OldestNewerInFlightStartAt { get; init; }
  33. /// <summary>见 <see cref="S8SnapshotInvariant"/>。</summary>
  34. public string SnapshotInvariant { get; init; } = S8SnapshotInvariant.NotApplicable;
  35. /// <summary>快照不变量被破坏时的细节,写进 Reason 便于排障。</summary>
  36. public string? SnapshotDetail { get; init; }
  37. public TimeSpan StaleWindow { get; init; } = TimeSpan.FromHours(12);
  38. }
  39. /// <summary>
  40. /// Authority 健康判定的<b>纯函数核心</b>。
  41. ///
  42. /// <para>刻意不依赖数据库、时钟与任何服务:所有输入都在
  43. /// <see cref="S8AuthorityObservation"/> 里显式给出,因此 A–K 全部场景都能用普通单测覆盖,
  44. /// 不需要连库、不需要 <c>AIDOP_IT</c> 门控(该门控在未设变量时是静默 return、显示为绿,
  45. /// 核心判定绝不能只靠它守)。</para>
  46. ///
  47. /// <para><b>不变量</b>:本文件<b>不存在</b>任何行数入参,因此
  48. /// 「取数为空 ⇒ 拦截恢复」这条错误逻辑在结构上写不出来。</para>
  49. /// </summary>
  50. public static class S8AuthorityHealthEvaluator
  51. {
  52. public static S8AuthorityHealthResult Evaluate(S8AuthorityObservation o)
  53. {
  54. ArgumentNullException.ThrowIfNull(o);
  55. var staleAfter = o.LastSuccessEndAt.HasValue
  56. ? o.LastSuccessEndAt.Value + o.StaleWindow
  57. : (DateTime?)null;
  58. S8AuthorityHealthResult Result(string state, string reasonCode, string reason) => new()
  59. {
  60. DatasetCode = o.DatasetCode,
  61. TenantId = o.TenantId,
  62. State = state,
  63. ReasonCode = reasonCode,
  64. Reason = reason,
  65. ObservedAt = o.ObservedAt,
  66. LastSuccessAt = o.LastSuccessEndAt,
  67. StaleAfter = staleAfter
  68. };
  69. // ① 没有声明 AuthoritySpec —— 无从判断,保守拦截。
  70. if (!o.SpecFound)
  71. return Result(S8AuthorityHealthState.Unknown, S8AuthorityHealthReason.NoAuthoritySpec,
  72. $"数据集 {o.DatasetCode} 未声明 AuthoritySpec,无法判定 Authority 健康度");
  73. // ② 生产者不可信(多写入方且部分不留运行日志)—— run-log 证明不了 provenance。
  74. if (!o.ProducerTrusted)
  75. return Result(S8AuthorityHealthState.Unknown, o.UntrustedReasonCode,
  76. $"数据集 {o.DatasetCode} 的 Authority 存在多个写入方且部分不写运行日志,运行日志无法证明数据出处");
  77. // ③ 回溯窗口内没有任何成功运行。含「从未同步」与「停摆超过回溯窗口」。
  78. if (!o.LastSuccessEndAt.HasValue)
  79. return Result(S8AuthorityHealthState.Unknown, S8AuthorityHealthReason.NoSuccessfulRun,
  80. $"回溯窗口内没有 {o.DatasetCode} 的成功生产运行");
  81. // ④ 最近一次成功之后出现终态失败 —— 之后的数据状态不可信。
  82. // 先于 STALE 判定,因为「失败」比「过期」更具体、更有排障价值。
  83. if (o.HasNewerTerminalFailure)
  84. {
  85. var isPartial = string.Equals(o.NewerTerminalFailureStatus, "PARTIAL", StringComparison.OrdinalIgnoreCase);
  86. return Result(S8AuthorityHealthState.Degraded,
  87. isPartial ? S8AuthorityHealthReason.LatestPartial : S8AuthorityHealthReason.LatestFailed,
  88. $"最近一次成功({o.LastSuccessEndAt:yyyy-MM-dd HH:mm:ss})之后出现终态 " +
  89. $"{o.NewerTerminalFailureStatus ?? "非成功"} 运行");
  90. }
  91. // ⑤ 存在比最近一次成功更新、且尚未终态的运行 —— 本轮 Authority 可能只写了一半。
  92. // IN_FLIGHT 与 STUCK_RUN 都拦截,区别只在排障动作(等它跑完 / 去清孤儿行)。
  93. if (o.OldestNewerInFlightStartAt.HasValue)
  94. {
  95. var age = o.ObservedAt - o.OldestNewerInFlightStartAt.Value;
  96. var stuck = age > o.StaleWindow;
  97. return Result(S8AuthorityHealthState.Degraded,
  98. stuck ? S8AuthorityHealthReason.StuckRun : S8AuthorityHealthReason.InFlight,
  99. $"存在比最近一次成功更新且未终态的生产运行(开始于 " +
  100. $"{o.OldestNewerInFlightStartAt:yyyy-MM-dd HH:mm:ss},已历时 {age.TotalMinutes:F0} 分钟)");
  101. }
  102. // ⑥ 最近一次成功已过期。
  103. if (o.ObservedAt > staleAfter!.Value)
  104. return Result(S8AuthorityHealthState.Degraded, S8AuthorityHealthReason.Stale,
  105. $"最近一次成功于 {o.LastSuccessEndAt:yyyy-MM-dd HH:mm:ss}," +
  106. $"已超过 {o.StaleWindow.TotalHours:F1} 小时的新鲜度窗口");
  107. // ⑦ 快照不变量被破坏。
  108. if (string.Equals(o.SnapshotInvariant, S8SnapshotInvariant.Violated, StringComparison.Ordinal))
  109. return Result(S8AuthorityHealthState.Degraded, S8AuthorityHealthReason.SnapshotInvariantFailed,
  110. $"当前快照不变量被破坏:{o.SnapshotDetail ?? "(无细节)"}");
  111. // ⑧ 快照发布无法确认 —— 合法空快照与发布失败字面等同,不猜,判 UNKNOWN。
  112. if (string.Equals(o.SnapshotInvariant, S8SnapshotInvariant.Unconfirmed, StringComparison.Ordinal))
  113. return Result(S8AuthorityHealthState.Unknown, S8AuthorityHealthReason.SnapshotPublicationUnconfirmed,
  114. $"无法确认本轮快照是否发布成功:{o.SnapshotDetail ?? "当前批次数为 0"}");
  115. // ⑨ 通过。注意:走到这里从未读过任何行数 —— Authority 可信但一行都没有,同样是 HEALTHY。
  116. return Result(S8AuthorityHealthState.Healthy, S8AuthorityHealthReason.Healthy,
  117. $"最近一次成功于 {o.LastSuccessEndAt:yyyy-MM-dd HH:mm:ss},在新鲜度窗口内,无更新的失败或在途运行");
  118. }
  119. }