AidopInstanceIdentity.cs 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. using System.Diagnostics;
  2. using System.Reflection;
  3. namespace Admin.NET.Plugin.AiDOP.Infrastructure;
  4. /// <summary>
  5. /// 本进程在 ETL 执行机注册表(<c>ado_etl_instance</c>)中的身份。
  6. ///
  7. /// <para><b>为什么要进程级唯一</b>:同一台机器可能同时跑多个后端进程(开发时的
  8. /// <c>dotnet run</c> + 已发布的服务),它们连的是同一个库。若只用机器名做标识,
  9. /// 两个进程会抢同一行注册记录,指派给谁都说不清。故标识取
  10. /// <c>机器名:进程ID:启动时间</c> 三段,进程重启后必然产生新行。</para>
  11. ///
  12. /// <para><b>为什么用启动时间而不是随机 GUID</b>:运维要在页面上认出「这是哪台、什么时候起的」。
  13. /// GUID 每次重启都变且不可读,排障时无法和服务器上的进程对上。</para>
  14. ///
  15. /// <para><b>身份与角色是两件事</b>:<see cref="InstanceId"/> 只回答「我是哪次进程」,
  16. /// 执行机角色则指派给 <see cref="SlotCode"/> 这个跨重启稳定的部署槽位
  17. /// (见 <c>AdoEtlRunnerDesignation</c>)。把角色挂在进程级标识上会让每次重启都丢指派。</para>
  18. ///
  19. /// <para>静态只读,进程生命周期内不变,可在任意线程反复读取。</para>
  20. /// </summary>
  21. public static class AidopInstanceIdentity
  22. {
  23. static AidopInstanceIdentity()
  24. {
  25. MachineName = SafeGet(() => Environment.MachineName, "unknown-host");
  26. ProcessId = SafeGet(() => Environment.ProcessId, 0);
  27. StartedAt = SafeGet(() => Process.GetCurrentProcess().StartTime, DateTime.Now);
  28. AppVersion = SafeGet(
  29. () => Assembly.GetEntryAssembly()?.GetName().Version?.ToString(),
  30. null);
  31. // 长度受 ado_etl_instance.instance_id 的 200 字符约束;机器名过长时截断,
  32. // 进程ID + 启动时间仍能保证同机不同进程互不覆盖。
  33. var host = MachineName.Length > 120 ? MachineName[..120] : MachineName;
  34. InstanceId = $"{host}:{ProcessId}:{StartedAt:yyyyMMddHHmmss}";
  35. }
  36. /// <summary>注册表主键语义列,形如 <c>DESKTOP-ABC:12345:20260925071200</c>。</summary>
  37. public static string InstanceId { get; }
  38. public static string MachineName { get; }
  39. public static int ProcessId { get; }
  40. /// <summary>本进程启动时间。取不到时退化为静态构造时刻,不影响唯一性。</summary>
  41. public static DateTime StartedAt { get; }
  42. /// <summary>入口程序集版本,仅供页面展示与排障;取不到为 null。</summary>
  43. public static string AppVersion { get; }
  44. /// <summary>配置键:本进程所属的 ETL 执行机槽位。</summary>
  45. public const string SlotConfigKey = "AiDOP:Jobs:Slot";
  46. /// <summary>环境变量名:优先级高于配置,便于容器与服务单元各自声明。</summary>
  47. public const string SlotEnvName = "AIDOP_ETL_SLOT";
  48. /// <summary>
  49. /// 本进程所属的执行机槽位;未声明则为 null,**此时本进程永远不可能成为执行机**。
  50. ///
  51. /// <para>这比旧模型更严:旧模型下任何一台注册上来的进程都能被指派,
  52. /// 开发机被指派正是 2026-09-24 连接耗尽事故的成因之一。未声明槽位者连候选都不是。</para>
  53. ///
  54. /// <para><b>槽位不同于「配置里写自己是不是执行机」</b>:配置只声明身份(我是哪个槽位),
  55. /// 唯一权威仍在库里那条指派记录。这保留了 2026-09-25 治理的核心——
  56. /// 单一开关、页面可见、热切换无需重启——只是让指派能跨重启存活。</para>
  57. ///
  58. /// <para><b>为什么惰性求值</b>:静态构造可能早于宿主配置就绪,
  59. /// 那时读 <c>App.GetConfig</c> 会抛,若在构造里一次性定下来就会永久退化成 null。
  60. /// 首次访问来自心跳(启动后 5 秒),此时配置一定已就绪。</para>
  61. /// </summary>
  62. public static string SlotCode => _slotCode.Value;
  63. private static readonly Lazy<string> _slotCode = new(ResolveSlotCode, LazyThreadSafetyMode.ExecutionAndPublication);
  64. private static string ResolveSlotCode()
  65. {
  66. var fromEnv = SafeGet(() => Environment.GetEnvironmentVariable(SlotEnvName), null);
  67. if (!string.IsNullOrWhiteSpace(fromEnv))
  68. return Normalize(fromEnv);
  69. var fromConfig = SafeGet(() => Furion.App.GetConfig<string>(SlotConfigKey, true), null);
  70. return string.IsNullOrWhiteSpace(fromConfig) ? null : Normalize(fromConfig);
  71. }
  72. /// <summary>槽位码要能被人工比对,故统一去空白并折成小写,长度受 128 字符列约束。</summary>
  73. private static string Normalize(string raw)
  74. {
  75. var v = raw.Trim().ToLowerInvariant();
  76. return v.Length > 128 ? v[..128] : v;
  77. }
  78. /// <summary>
  79. /// 身份计算不得成为启动故障点:容器内取进程启动时间、受限环境取机器名都可能抛异常,
  80. /// 一旦抛出会让整个插件装配失败,而这里拿到的只是展示用信息。
  81. /// </summary>
  82. private static T SafeGet<T>(Func<T> get, T fallback)
  83. {
  84. try { return get(); }
  85. catch { return fallback; }
  86. }
  87. }