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