MigrationRecoveryPolicy.cs 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. namespace Admin.NET.Core.Update;
  2. /// <summary>已知失败迁移的恢复裁决。</summary>
  3. public enum MigrationRecoveryDecision
  4. {
  5. /// <summary>不做任何事(绝大多数情况)。</summary>
  6. None = 0,
  7. /// <summary>重跑前清空目标表,然后让原脚本原样重跑。</summary>
  8. Truncate = 1,
  9. /// <summary>拒绝并停机:命中了登记版本,但 hash 对不上,不能对未知脚本执行破坏性操作。</summary>
  10. Reject = 2
  11. }
  12. /// <summary>一条已发布且已知会失败的迁移,及其恢复策略。</summary>
  13. public sealed record MigrationRecoveryPlan(
  14. string Version,
  15. string OriginalFileHash,
  16. string Strategy,
  17. string TableName);
  18. /// <summary>裁决结果。</summary>
  19. public sealed record MigrationRecoveryOutcome(
  20. MigrationRecoveryDecision Decision,
  21. MigrationRecoveryPlan? Plan,
  22. string? RejectReason);
  23. /// <summary>
  24. /// 已知失败迁移的「重跑前恢复」策略。<b>纯函数,不碰数据库</b>,effect 由调用方执行。
  25. ///
  26. /// <para><b>这不是迁移的正常语义,而是针对特定版本的一次性抢救。</b>
  27. /// 适用前提是:脚本已经 push、hash 已被多个环境记录,此时
  28. /// · 不能改脚本 —— 已 Success 的环境会因 hash 不符而拒绝启动;
  29. /// · 不能手改迁移日志 —— 那是伪造执行历史。
  30. /// 唯一安全的口子是在<b>重跑之前</b>把「让它跑不完的那点状态」清掉,
  31. /// 再让原脚本原样重跑成功,hash 与执行历史都保持真实。</para>
  32. ///
  33. /// <para><b>1.0.517 的具体情况</b>:脚本末尾的
  34. /// <c>DELETE FROM dwd_requirement_examine_detail WHERE material_role IS NULL</c>
  35. /// 谓词正确但跑不完 —— <c>material_role</c> 是同一脚本刚 ADD 的列、无索引,
  36. /// 实测共享库命中 1,028,210 行 ≈ 100%,单条语句 elapsed 645,038ms,
  37. /// 超过迁移执行器 600s 的 CommandTimeout ⇒ Failed;而 Failed 脚本每次启动都会重跑、
  38. /// StopApplicationOnFailure 默认 true,于是形成「启动→重试→超时→停机」的死循环。
  39. /// 该表是 100% 派生、读者数为 0 的 DWD,下一轮 S1 跑批即完整重建,
  40. /// 因此重跑前 TRUNCATE 是安全的:原 DELETE 随后命中 0 行、瞬间完成。</para>
  41. /// </summary>
  42. public static class MigrationRecoveryPolicy
  43. {
  44. /// <summary>
  45. /// 恢复登记表。<b>只按版本号精确登记,不做任何模式匹配、不按脚本内容推断。</b>
  46. /// 新增条目等于授权一次破坏性操作,必须逐条评审。
  47. /// </summary>
  48. public static readonly IReadOnlyList<MigrationRecoveryPlan> KnownFailedRecoveries = new[]
  49. {
  50. new MigrationRecoveryPlan(
  51. Version: "1.0.517",
  52. OriginalFileHash: "EF2722954B4BB5BEA465F63E764C927F01E9E52109738478E89CE94145ECB50B",
  53. Strategy: "DWD_TRUNCATE_PRE_RETRY",
  54. TableName: "dwd_requirement_examine_detail")
  55. };
  56. /// <summary>
  57. /// 裁决是否要在重跑前做恢复。四道闸门,全部通过才 <see cref="MigrationRecoveryDecision.Truncate"/>。
  58. /// </summary>
  59. /// <param name="version">待执行脚本的版本号。</param>
  60. /// <param name="loggedStatus">迁移日志里该版本的状态;从未跑过时传 null。</param>
  61. /// <param name="loggedFileHash">迁移日志里记录的脚本 SHA256。</param>
  62. /// <param name="diskHashMatchesOriginal">磁盘上的脚本内容是否仍等于登记的原始 hash。</param>
  63. public static MigrationRecoveryOutcome Decide(
  64. string version,
  65. string? loggedStatus,
  66. string? loggedFileHash,
  67. bool diskHashMatchesOriginal)
  68. {
  69. // 闸门 1:版本必须精确登记。
  70. var plan = KnownFailedRecoveries.FirstOrDefault(x =>
  71. string.Equals(x.Version, version, StringComparison.OrdinalIgnoreCase));
  72. if (plan == null)
  73. return new MigrationRecoveryOutcome(MigrationRecoveryDecision.None, null, null);
  74. // 闸门 2:必须已经跑过且失败。
  75. // 从未跑过(无日志行)= 正常首次执行,不是抢救场景;
  76. // 已 Success 的根本走不到这里(ShouldSkipScript 已把它排除出 pending),
  77. // 这里再判一次是纵深防御,确保本策略永远不会碰成功过的环境。
  78. if (string.IsNullOrWhiteSpace(loggedStatus))
  79. return new MigrationRecoveryOutcome(MigrationRecoveryDecision.None, plan, null);
  80. if (!string.Equals(loggedStatus, "Failed", StringComparison.OrdinalIgnoreCase))
  81. return new MigrationRecoveryOutcome(MigrationRecoveryDecision.None, plan, null);
  82. // 闸门 3:日志记录的 hash 必须等于已发布的原始脚本 hash。
  83. // 不符 = 「这个 1.0.517 不是我们登记的那一个」,
  84. // 此时继续等于拿不可回滚的 TRUNCATE 去赌未知脚本 ⇒ 显式拒绝。
  85. if (!string.Equals(loggedFileHash?.Trim(), plan.OriginalFileHash, StringComparison.OrdinalIgnoreCase))
  86. return new MigrationRecoveryOutcome(
  87. MigrationRecoveryDecision.Reject, plan,
  88. $"迁移日志记录的 SHA256 与已登记的原始脚本不一致:记录={loggedFileHash},登记={plan.OriginalFileHash}");
  89. // 闸门 4:磁盘脚本也必须仍是原始内容(防止本地改过脚本又恰好处于 Failed)。
  90. if (!diskHashMatchesOriginal)
  91. return new MigrationRecoveryOutcome(
  92. MigrationRecoveryDecision.Reject, plan,
  93. "磁盘脚本内容与已登记的原始脚本不一致,恢复策略只针对已发布的原始脚本");
  94. return new MigrationRecoveryOutcome(MigrationRecoveryDecision.Truncate, plan, null);
  95. }
  96. }