diff --git a/.github/workflows/topology-equivalence.yml b/.github/workflows/topology-equivalence.yml index 6dc7feacd..3532451b7 100644 --- a/.github/workflows/topology-equivalence.yml +++ b/.github/workflows/topology-equivalence.yml @@ -3,6 +3,8 @@ name: Topology Equivalence Gate # C143c D9:跨 Role 消息链路语义等价 CI 门禁。 # 4 条代表性链路 × 2 拓扑(All-in-One 单进程 / 三进程)在同一套固定预期下运行, # 任一用例失败即阻塞合并。这是 DynamicPhase-0 核心契约(AC-2)的守护门。 +# C143d:套件扩入 Discovery(地址格式 / 双视图路由 / Mongo 心跳集成用例), +# sidecar Mongo 让 GAMEFRAMEX_TEST_MONGODB_CONNECTION_STRING 门控的集成用例真跑。 on: pull_request: @@ -13,6 +15,18 @@ on: jobs: topology-equivalence: runs-on: ubuntu-latest + services: + mongo: + image: mongo:7 + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --quiet --eval \"db.adminCommand('ping')\"" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + GAMEFRAMEX_TEST_MONGODB_CONNECTION_STRING: mongodb://localhost:27017 steps: - name: Checkout Repository uses: actions/checkout@v3 @@ -31,5 +45,5 @@ jobs: - name: Run Topology Equivalence Suite run: > dotnet test Tests/GameFrameX.Tests/GameFrameX.Tests.csproj --no-build - --filter "FullyQualifiedName~GameFrameX.Tests.Topology.Equivalence" + --filter "FullyQualifiedName~GameFrameX.Tests.Topology.Equivalence|FullyQualifiedName~GameFrameX.Tests.Discovery" --logger GitHubActions diff --git a/GameFrameX.Launcher/StartUp/AppStartUpGame.cs b/GameFrameX.Launcher/StartUp/AppStartUpGame.cs index d0e2bbd66..241634c97 100644 --- a/GameFrameX.Launcher/StartUp/AppStartUpGame.cs +++ b/GameFrameX.Launcher/StartUp/AppStartUpGame.cs @@ -30,6 +30,7 @@ using GameFrameX.DataBase; using GameFrameX.DataBase.Abstractions; +using GameFrameX.NetWork.RemoteMessaging.Discovery; using GameFrameX.Foundation.Utility; using GameFrameX.Foundation.Localization.Core; using GameFrameX.Online.Runtime; @@ -82,6 +83,10 @@ public override async Task StartAsync() } } + // C143d D11-D15:控制库就绪后激活 Mongo 发现层——读侧 watcher + 写侧心跳(未配置广播端口时自动跳过) + // 并以真实 case 2/3 转发器重装跨 Role 路由缝(替换 C143c 占位)。幂等:多 Role 进程首个调用生效。 + MongoDiscoveryRuntime.Activate(((MongoDbService)MultiDbRegistry.Get(MultiDbRegistry.ControlDatabaseName)).CurrentDatabase, RoleSet.Current); + var initResult = await GameDb.Init(Setting.DataBaseUrl, new DbOptions { Name = Setting.DataBaseName, IsUseTimeZone = Setting.IsUseTimeZone, }); if (initResult == false) { @@ -120,6 +125,9 @@ public override async Task StartAsync() LogHelper.Info(LocalizationService.GetString(Localization.Keys.Launcher.ServerStartEnd, Setting.ServerType)); // C143b D7:启动阶段完成(DB/组件/热-fix/在线管理均已就绪),放行下一个 Role 的启动屏障 MarkStartUpReady(); + // C143d D15:启动阶段真正完成(DB/组件/热-fix/在线管理均已就绪)后才把心跳从 Booting 切到 Active, + // 避免其他进程在服务就绪前发现本实例并投递流量;未激活发现层或无广播身份时为无害 no-op。 + MongoDiscoveryRuntime.MarkActive(); exitMessage = await AppExitToken; } catch (Exception e) diff --git a/GameFrameX.Launcher/StartUp/Social/AppStartUpSocial.cs b/GameFrameX.Launcher/StartUp/Social/AppStartUpSocial.cs index cf73af38f..1edd6a765 100644 --- a/GameFrameX.Launcher/StartUp/Social/AppStartUpSocial.cs +++ b/GameFrameX.Launcher/StartUp/Social/AppStartUpSocial.cs @@ -31,6 +31,7 @@ using GameFrameX.Core.Components; using GameFrameX.DataBase; using GameFrameX.DataBase.Abstractions; +using GameFrameX.NetWork.RemoteMessaging.Discovery; using GameFrameX.NetWork.Abstractions; using GameFrameX.NetWork.HTTP; using GameFrameX.NetWork.Message; @@ -68,6 +69,10 @@ public override async Task StartAsync() } } + // C143d D11-D15:控制库就绪后激活 Mongo 发现层——读侧 watcher + 写侧心跳(未配置广播端口时自动跳过) + // 并以真实 case 2/3 转发器重装跨 Role 路由缝(替换 C143c 占位)。幂等:多 Role 进程首个调用生效。 + MongoDiscoveryRuntime.Activate(((MongoDbService)MultiDbRegistry.Get(MultiDbRegistry.ControlDatabaseName)).CurrentDatabase, RoleSet.Current); + var initResult = await GameDb.Init(Setting.DataBaseUrl, new DbOptions { Name = Setting.DataBaseName, IsUseTimeZone = Setting.IsUseTimeZone, }); if (initResult == false) { @@ -81,6 +86,9 @@ public override async Task StartAsync() // C143b D7:启动阶段完成(DB/组件/网络监听均已就绪),放行下一个 Role 的启动屏障 MarkStartUpReady(); + // C143d D15:启动阶段真正完成(DB/组件/网络监听均已就绪)后才把心跳从 Booting 切到 Active, + // 避免其他进程在 Social TCP listener 就绪前发现本实例并投递流量。 + MongoDiscoveryRuntime.MarkActive(); await AppExitToken; } diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/EndpointAddressKind.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/EndpointAddressKind.cs new file mode 100644 index 000000000..809553439 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/EndpointAddressKind.cs @@ -0,0 +1,67 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// 端点主机形态(C143d D15 addressKind 字段)。 +/// +/// +/// The host address kind carried by heartbeat documents (C143d D15 addressKind field). +/// The DNS name is the first-class default per D15: advertised addresses prefer +/// stable DNS names (container name / Kubernetes Service name / domain) over +/// ephemeral IP literals so restarts keep a routable identity. +/// +public enum EndpointAddressKind +{ + /// + /// 域名/容器名/Kubernetes Service 名(一等缺省形态)。 + /// + /// + /// Domain, container name, or Kubernetes Service name (the first-class default). + /// + DnsName = 1, + + /// + /// IPv4 字面量。 + /// + /// + /// An IPv4 literal. + /// + IPv4 = 2, + + /// + /// IPv6 字面量(方括号形式)。 + /// + /// + /// An IPv6 literal (bracketed form). + /// + IPv6 = 3, +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/EndpointFormatException.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/EndpointFormatException.cs new file mode 100644 index 000000000..33266e03c --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/EndpointFormatException.cs @@ -0,0 +1,56 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// 端点地址格式异常(C143d D15)。 +/// +/// +/// Thrown by when an endpoint string violates the +/// unified scheme://host:port format: missing scheme, missing or out-of-range +/// port, or an unparsable host. Failing loudly at parse time (instead of at connect +/// time with an opaque socket error) is the AC-4a contract: every endpoint shape is +/// validated by the same single parser. +/// +public sealed class EndpointFormatException : FormatException +{ + /// + /// 初始化端点格式异常。 + /// + /// + /// Initializes the exception with a descriptive message. + /// + /// 描述违规原因的消息 / The message describing the violation + public EndpointFormatException(string message) + : base(message) + { + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/EndpointParser.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/EndpointParser.cs new file mode 100644 index 000000000..e63f72ed3 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/EndpointParser.cs @@ -0,0 +1,242 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using System.Globalization; +using System.Net; +using System.Net.Sockets; + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// 统一端点地址解析器(C143d D15 / AC-4a)。 +/// +/// +/// The single unified endpoint parser (C143d D15 / AC-4a). +/// Every endpoint in the topology is stored as an unparsed scheme://host:port +/// string and parsed only at connect time by this class, so heartbeat documents, +/// services__* environment variables, and advertise addresses all share one +/// validation surface. Supported host shapes: domain names, container names, +/// Kubernetes Service names, IPv4 literals, and bracketed IPv6 literals +/// ([::1]). is intentionally not used: it fills +/// scheme default ports for ws/wss, which would silently mask a missing port +/// (a structural error this parser must report). +/// +public static class EndpointParser +{ + /// + /// scheme 与 authority 之间的分隔符。 + /// + /// + /// The separator between the scheme and the authority part. + /// + private const string SchemeSeparator = "://"; + + /// + /// 解析统一格式的端点地址。 + /// + /// + /// Parses a unified scheme://host:port endpoint string. + /// The scheme must be one of tcp/kcp/ws/wss (lower or upper case; compared + /// ordinally ignore-case and returned lower-cased); the host may be a domain, + /// container name, Kubernetes Service name, IPv4 literal, or a bracketed IPv6 + /// literal; the port must be 1-65535. Any structural violation throws + /// — never a partially-filled result. + /// + /// 端点地址字符串(scheme://host:port)/ The endpoint string (scheme://host:port) + /// 解析后的三元组 / The parsed endpoint triple + /// 为 null 时抛出 / Thrown when endpoint is null + /// 当 scheme 缺失/不受支持、host 缺失、端口缺失或越界时抛出 / Thrown on missing/unsupported scheme, missing host, or missing/out-of-range port + public static ParsedEndpoint Parse(string endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint, nameof(endpoint)); + + var trimmed = endpoint.Trim(); + if (trimmed.Length == 0) + { + throw new EndpointFormatException("The endpoint string is empty. Expected the unified 'scheme://host:port' format (e.g. 'tcp://game-1.gameframex:7777')."); + } + + var separatorIndex = trimmed.IndexOf(SchemeSeparator, StringComparison.Ordinal); + if (separatorIndex <= 0 || separatorIndex + SchemeSeparator.Length >= trimmed.Length) + { + throw new EndpointFormatException($"The endpoint '{endpoint}' does not contain the required 'scheme://host:port' structure: the '://' separator with a non-empty scheme and authority is missing."); + } + + var scheme = trimmed.Substring(0, separatorIndex).ToLowerInvariant(); + if (!IsSupportedScheme(scheme)) + { + throw new EndpointFormatException($"The endpoint '{endpoint}' uses the unsupported scheme '{scheme}'. Supported schemes: tcp, kcp, ws, wss."); + } + + var authority = trimmed.Substring(separatorIndex + SchemeSeparator.Length); + return ParseAuthority(endpoint, scheme, authority); + } + + /// + /// 解析 authority 段(host[:port] 或 [ipv6]:port)。 + /// + /// + /// Parses the authority part. Bracketed hosts are IPv6 literals; everything else + /// splits on the last colon into host and port. + /// + /// 原始输入(仅用于异常消息)/ The original input (used in exception messages only) + /// 已校验的小写 scheme / The validated lower-cased scheme + /// authority 段 / The authority part + /// 解析后的三元组 / The parsed endpoint triple + /// 当结构违规时抛出 / Thrown on structural violations + private static ParsedEndpoint ParseAuthority(string originalEndpoint, string scheme, string authority) + { + if (authority.Length == 0) + { + throw new EndpointFormatException($"The endpoint '{originalEndpoint}' has an empty host after the scheme."); + } + + // IPv6 方括号字面量:[::1]:port + if (authority[0] == '[') + { + var closingBracketIndex = authority.IndexOf(']'); + if (closingBracketIndex < 0 || closingBracketIndex == 1) + { + throw new EndpointFormatException($"The endpoint '{originalEndpoint}' has a malformed bracketed IPv6 host: expected '[]:'."); + } + + var ipv6Host = authority.Substring(1, closingBracketIndex - 1); + if (!IPAddress.TryParse(ipv6Host, out var bracketedAddress) || bracketedAddress.AddressFamily != AddressFamily.InterNetworkV6) + { + throw new EndpointFormatException($"The endpoint '{originalEndpoint}' has a bracketed host '{ipv6Host}' that is not a valid IPv6 literal."); + } + + var remainder = authority.Substring(closingBracketIndex + 1); + if (remainder.Length == 0 || remainder[0] != ':') + { + throw new EndpointFormatException($"The endpoint '{originalEndpoint}' is missing the port after the bracketed IPv6 host: expected '[]:'."); + } + + var port = ParsePort(originalEndpoint, remainder.Substring(1)); + return new ParsedEndpoint(scheme, ipv6Host, port, EndpointAddressKind.IPv6); + } + + // 域名/容器名/Service 名/IPv4:最后一个冒号分隔端口 + var lastColonIndex = authority.LastIndexOf(':'); + if (lastColonIndex < 0 || lastColonIndex == authority.Length - 1) + { + throw new EndpointFormatException($"The endpoint '{originalEndpoint}' is missing the port: expected 'scheme://host:port'."); + } + + var host = authority.Substring(0, lastColonIndex); + if (host.Length == 0) + { + throw new EndpointFormatException($"The endpoint '{originalEndpoint}' has an empty host before the port."); + } + + if (IPAddress.TryParse(host, out var address)) + { + if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + throw new EndpointFormatException($"The endpoint '{originalEndpoint}' uses an unbracketed IPv6 literal '{host}'; IPv6 hosts must be bracketed as '[]:'."); + } + + return new ParsedEndpoint(scheme, host, ParsePort(originalEndpoint, authority.Substring(lastColonIndex + 1)), EndpointAddressKind.IPv4); + } + + ValidateDnsHost(originalEndpoint, host); + return new ParsedEndpoint(scheme, host, ParsePort(originalEndpoint, authority.Substring(lastColonIndex + 1)), EndpointAddressKind.DnsName); + } + + /// + /// DNS/容器名/Service 名中不合法的字符(URI 分隔符与端口分隔符)。 + /// + /// + /// Characters never legal inside a DNS, container, or Service name + /// (URI delimiters and the port separator). + /// + private const string InvalidHostCharacters = "/?#@:"; + + /// + /// 校验非 IP host 是合法的 DNS/容器名/Service 名(空白与 URI 分隔符立即拒绝)。 + /// + /// + /// Validates a non-IP host as a DNS, container, or Service name: whitespace + /// and the URI delimiter characters (/ ? # @ :) are rejected at parse + /// time, so malformed inputs such as tcp://user@host:7777 or + /// tcp://host/path:7777 fail fast here instead of surfacing as an + /// unexplainable connect failure later. + /// + /// 原始输入(仅用于异常消息)/ The original input (used in exception messages only) + /// 待校验的 host / The host to validate + /// 当 host 含空白或 URI 分隔符时抛出 / Thrown when the host contains whitespace or a URI delimiter + private static void ValidateDnsHost(string originalEndpoint, string host) + { + foreach (var character in host) + { + if (char.IsWhiteSpace(character) || InvalidHostCharacters.IndexOf(character) >= 0) + { + throw new EndpointFormatException($"The endpoint '{originalEndpoint}' has the invalid host '{host}': whitespace and the URI delimiters '/', '?', '#', '@' and ':' are not allowed in a DNS, container, or Service name."); + } + } + } + + /// + /// 解析并校验端口(1–65535)。 + /// + /// + /// Parses and validates the port (1-65535). A port of 0 is rejected on purpose: + /// it is never a routable advertise port, only a bind-side wildcard. + /// + /// 原始输入(仅用于异常消息)/ The original input (used in exception messages only) + /// 端口文本 / The port text + /// 端口值 / The port value + /// 当端口非数字或越界时抛出 / Thrown when the port is not numeric or out of range + private static int ParsePort(string originalEndpoint, string portText) + { + if (!int.TryParse(portText, NumberStyles.None, CultureInfo.InvariantCulture, out var port) || port < 1 || port > 65535) + { + throw new EndpointFormatException($"The endpoint '{originalEndpoint}' has the invalid port '{portText}': expected an integer between 1 and 65535."); + } + + return port; + } + + /// + /// 判断 scheme 是否受支持(tcp/kcp/ws/wss)。 + /// + /// + /// Determines whether the scheme is supported (tcp/kcp/ws/wss). + /// + /// 小写 scheme / The lower-cased scheme + /// 受支持返回 true;否则 false / true when supported; otherwise false + private static bool IsSupportedScheme(string scheme) + { + return string.Equals(scheme, "tcp", StringComparison.Ordinal) + || string.Equals(scheme, "kcp", StringComparison.Ordinal) + || string.Equals(scheme, "ws", StringComparison.Ordinal) + || string.Equals(scheme, "wss", StringComparison.Ordinal); + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/IRoleInstanceEvents.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/IRoleInstanceEvents.cs new file mode 100644 index 000000000..d78f4881a --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/IRoleInstanceEvents.cs @@ -0,0 +1,52 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// 实例上下线事件订阅接口(C143d D15 / D17 通道 1 的进程内出口)。 +/// +/// +/// The in-process exit of D17 channel 1: subscribers are notified by the watcher +/// whenever the dual-view table changes shape (C143d D15). Implementations run on +/// the watcher poll loop thread and must not block; slow work must be queued elsewhere. +/// +public interface IRoleInstanceEvents +{ + /// + /// 实例变化回调。 + /// + /// + /// Called for every Online / Draining / Offline / Evicted / Recovered change. + /// + /// 变化类别 / The change kind + /// 变化后的实例描述符(Offline/Evicted 为摘除前最后一次观测)/ The descriptor after the change (for Offline/Evicted, the last observation before removal) + void OnInstanceChanged(RoleInstanceChangeKind kind, InstanceDescriptor instance); +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/IRoleRouteTableProvider.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/IRoleRouteTableProvider.cs new file mode 100644 index 000000000..36ce72ec9 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/IRoleRouteTableProvider.cs @@ -0,0 +1,53 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// 路由表快照提供者(C143d D15)。 +/// +/// +/// Provides the current dual-view route table snapshot (C143d D15). +/// is the production implementation; routing and +/// endpoint-resolution consumers depend on this narrow seam instead of the watcher +/// itself, so tests can substitute a fixed table without any Mongo dependency. +/// +public interface IRoleRouteTableProvider +{ + /// + /// 获取当前双视图路由表快照。 + /// + /// + /// Gets the current snapshot. Never returns null: before the first poll completes + /// the provider serves . + /// + /// 当前快照 / The current snapshot + RoleRouteTable Current { get; } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/InstanceDescriptor.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/InstanceDescriptor.cs new file mode 100644 index 000000000..77124a8b4 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/InstanceDescriptor.cs @@ -0,0 +1,148 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// 服务实例描述符(C143d D15:路由表与心跳文档共用的数据模型)。 +/// +/// +/// The data model shared by heartbeat documents and the dual-view route table +/// (C143d D15). One descriptor identifies one process hosting one role: who it is +/// ( + ), where it is reachable +/// (, stored unparsed by design), what state it is in +/// (), how loaded it is (), which host shape the +/// address uses (), and the restart epoch +/// ( — changed on restart so the watcher can tell +/// "same instance id came back" from "the old instance recovered"). +/// The descriptor is immutable after construction. +/// +public sealed class InstanceDescriptor +{ + /// + /// 初始化服务实例描述符。 + /// + /// + /// Initializes the descriptor. + /// + /// 承载的 Role 名(服务器类型名)/ The hosted role (server type name) + /// 实例唯一标识 / The unique instance id + /// 对外可达端点(未解析的 scheme://host:port)/ The reachable endpoint (unparsed scheme://host:port) + /// 实例状态 / The instance status + /// 负载值(0–100)/ The load value (0-100) + /// 地址形态 / The address kind + /// 代数(实例身份的重启纪元;重启后变化)/ The incarnation (restart epoch of the instance identity; changes on restart) + /// 最后一次心跳时间(UTC)/ The last heartbeat time (UTC) + public InstanceDescriptor(string role, string instanceId, string advertiseEndpoint, InstanceStatus status, int load, EndpointAddressKind addressKind, long incarnation, DateTime lastHeartbeatUtc) + { + Role = role; + InstanceId = instanceId; + AdvertiseEndpoint = advertiseEndpoint; + Status = status; + Load = load; + AddressKind = addressKind; + Incarnation = incarnation; + LastHeartbeatUtc = lastHeartbeatUtc; + } + + /// + /// 获取承载的 Role 名。 + /// + /// + /// Gets the hosted role name. + /// + /// Role 名 / The role name + public string Role { get; } + + /// + /// 获取实例唯一标识。 + /// + /// + /// Gets the unique instance id. + /// + /// 实例标识 / The instance id + public string InstanceId { get; } + + /// + /// 获取对外可达端点(未解析)。 + /// + /// + /// Gets the reachable advertise endpoint, stored unparsed by D15 design. + /// + /// scheme://host:port / The endpoint string + public string AdvertiseEndpoint { get; } + + /// + /// 获取实例状态。 + /// + /// + /// Gets the instance status. + /// + /// 实例状态 / The status + public InstanceStatus Status { get; } + + /// + /// 获取负载值(0–100)。 + /// + /// + /// Gets the load value (0-100). + /// + /// 负载值 / The load value + public int Load { get; } + + /// + /// 获取地址形态。 + /// + /// + /// Gets the address kind of . + /// + /// 地址形态 / The address kind + public EndpointAddressKind AddressKind { get; } + + /// + /// 获取代数(重启纪元)。 + /// + /// + /// Gets the incarnation. The same with a different + /// incarnation means the process restarted; the watcher then emits + /// Offline+Online instead of Recovered (D15 incarnation rule). + /// + /// 代数 / The incarnation + public long Incarnation { get; } + + /// + /// 获取最后一次心跳时间(UTC)。 + /// + /// + /// Gets the last heartbeat time in UTC; the watcher judges liveness against it. + /// + /// 最后心跳时间 / The last heartbeat time + public DateTime LastHeartbeatUtc { get; } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/InstanceStatus.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/InstanceStatus.cs new file mode 100644 index 000000000..cb871e2a6 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/InstanceStatus.cs @@ -0,0 +1,85 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// 服务实例状态(C143d D15:heartbeat 文档 status 字段)。 +/// +/// +/// The lifecycle status of a role instance (C143d D15: the heartbeat document status field). +/// Scale-down follows the graceful Draining-to-Stopped semantics of AC-4: a Draining +/// instance stays routable for in-flight deliveries (D3 case 2) but is excluded from +/// new any-instance selections (D3 case 3); Stopped is written on graceful exit so the +/// watcher does not have to wait for the TTL to expire. +/// Values start at 1 on purpose: an uninitialized field must never read as a valid status. +/// +public enum InstanceStatus +{ + /// + /// 启动中:进程已注册但尚未宣告可服务。 + /// + /// + /// Booting: registered but not yet announcing service readiness. + /// + Booting = 1, + + /// + /// 活跃:正常服务中,接收新流量。 + /// + /// + /// Active: serving normally, accepts new traffic. + /// + Active = 2, + + /// + /// 排水中:优雅缩容中,不接新流量、保留在途投递。 + /// + /// + /// Draining: graceful scale-down in progress; no new traffic, in-flight deliveries kept. + /// + Draining = 3, + + /// + /// 已停止:优雅退出时写入,不参与路由。 + /// + /// + /// Stopped: written on graceful exit; excluded from routing. + /// + Stopped = 4, + + /// + /// 已摘除:心跳文档被 TTL 清除或实例被移出拓扑。 + /// + /// + /// Removed: the heartbeat document was TTL-evicted or the instance left the topology. + /// + Removed = 5, +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoDiscoveryRuntime.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoDiscoveryRuntime.cs new file mode 100644 index 000000000..6f08b3f99 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoDiscoveryRuntime.cs @@ -0,0 +1,137 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Routing; +using MongoDB.Driver; + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// Mongo 发现层进程装配器(C143d D11–D15 落地接线)。 +/// +/// +/// The process-level wiring point for the Mongo discovery layer (C143d). +/// The launch flow calls once the control database +/// (gameframex_control) is registered in MultiDbRegistry: it starts the watcher +/// (read side), starts the registry (write side — skipped when no advertise port +/// is configured, e.g. single-process local development), and re-installs +/// with the real case 2/3 remote router in place of +/// the C143c placeholder. Activation is idempotent per process: the first call +/// wins, later calls (one per hosted role startup in a multi-role process) return +/// immediately. The local dispatcher stays null until C143e, per the C143c seam plan. +/// +public static class MongoDiscoveryRuntime +{ + /// + /// 装配互斥标志(首调胜出)。 + /// + /// + /// The idempotence flag (first call wins). + /// + private static int _activated; + + /// + /// 已创建的心跳写侧(进程生命周期持有,终态 Stopped 由其自身退出钩子负责)。 + /// + /// + /// The created heartbeat writer, held for the process lifetime; its exit hooks own the terminal Stopped write. + /// + private static MongoEndpointRegistry _registry; + + /// + /// 已创建的心跳读侧。 + /// + /// + /// The created heartbeat reader. + /// + private static MongoEndpointWatcher _watcher; + + /// + /// 激活 Mongo 发现层并重装跨 Role 路由缝。 + /// + /// + /// Activates the discovery layer. The watcher always starts (every process + /// observes the topology); the registry starts only when a advertise identity + /// exists (advertise port configured). is then + /// re-initialized over the hosted role names with the real remote router. + /// Call this after the control database is registered; calling it more than + /// once per process is a no-op. + /// + /// 控制库(gameframex_control)/ The control database + /// 本进程承载的 Role 名全集(RoleSet 快照)/ The full hosted role-name set (the RoleSet snapshot) + /// 为 null 时抛出 / Thrown when controlDatabase or hostedRoleNames is null + public static void Activate(IMongoDatabase controlDatabase, IEnumerable hostedRoleNames) + { + ArgumentNullException.ThrowIfNull(controlDatabase, nameof(controlDatabase)); + ArgumentNullException.ThrowIfNull(hostedRoleNames, nameof(hostedRoleNames)); + + if (Interlocked.CompareExchange(ref _activated, 1, 0) != 0) + { + return; + } + + var hostedRoles = hostedRoleNames as IReadOnlyCollection ?? hostedRoleNames.ToList(); + _watcher = new MongoEndpointWatcher(controlDatabase); + _watcher.StartAsync(CancellationToken.None).GetAwaiter().GetResult(); + + // 写侧需要唯一的广播身份:未配置广播端口(单进程本地开发等)时跳过注册,仅观察拓扑。 + // ponytail: 心跳文档是单 Role 模型,多 Role 进程只广播首选 Role——目标形态(D13 compose 单 Role 服务)下 + // 多 Role 进程仅剩 AllInOne 开发形态,其路由全走本地 case 1,无跨进程发现需求;若未来多 Role 常态化 + // 再扩展为每 Role 一份心跳文档。 + var primaryRoleName = hostedRoles.Count > 0 ? hostedRoles.First() : "unknown"; + var selfDescriptor = MongoEndpointRegistry.CreateSelfDescriptorFromEnvironment(primaryRoleName); + if (selfDescriptor != null) + { + _registry = new MongoEndpointRegistry(controlDatabase, selfDescriptor); + _registry.StartAsync(CancellationToken.None).GetAwaiter().GetResult(); + } + else + { + LogHelper.Warning("[MongoDiscoveryRuntime] no advertise port configured ({environmentVariable}); the heartbeat write side is skipped and this process only observes the topology", MongoEndpointRegistry.AdvertisePortEnvironmentVariable); + } + + RoleRouterHolder.Initialize(new InProcessRoleRouter(hostedRoles, null, new MongoDiscoveryRemoteRoleRouter(_watcher, new TcpEnvelopeForwarder()))); + } + + /// + /// 把本进程心跳从 Booting 切换为 Active(启动阶段真正完成、服务就绪后调用)。 + /// + /// + /// Flips this process's announced status from Booting to Active. The startup + /// flows call this right after their readiness point (MarkStartUpReady: + /// databases, components, and listeners up) so other processes never discover + /// and route traffic to a not-yet-ready instance. No-op when the discovery + /// layer was not activated or the write side was skipped (no advertise identity). + /// + public static void MarkActive() + { + _registry?.MarkActiveAsync(CancellationToken.None).GetAwaiter().GetResult(); + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoDiscoveryServiceEndpointResolver.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoDiscoveryServiceEndpointResolver.cs new file mode 100644 index 000000000..90da3563e --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoDiscoveryServiceEndpointResolver.cs @@ -0,0 +1,91 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// 基于发现层的动态服务端点解析器(C143d D15)。 +/// +/// +/// The dynamic IServiceEndpointResolver backed by the discovery layer (C143d D15). +/// stays as the static-bootstrap implementation +/// (frozen, not removed); this counterpart resolves a service name — which in the +/// dynamic topology is the role name — to the advertise endpoint of one of its +/// Active instances from the watcher's dual-view table. It lets the existing RPC +/// transport chain (RemoteMessageClient over ITransportProtocolAdapter) consume +/// the dynamic table without any change to the chain itself. +/// +internal sealed class MongoDiscoveryServiceEndpointResolver : IServiceEndpointResolver +{ + /// + /// 双视图路由表提供者。 + /// + /// + /// The dual-view route table provider (the watcher in production). + /// + private readonly IRoleRouteTableProvider _tableProvider; + + /// + /// 初始化动态服务端点解析器。 + /// + /// + /// Initializes the resolver. + /// + /// 双视图路由表提供者 / The dual-view route table provider + public MongoDiscoveryServiceEndpointResolver(IRoleRouteTableProvider tableProvider) + { + ArgumentNullException.ThrowIfNull(tableProvider, nameof(tableProvider)); + + _tableProvider = tableProvider; + } + + /// + /// 解析指定服务(Role 名)的 TCP 端点地址。 + /// + /// + /// Resolves the TCP endpoint for the given service (role) name from the Active + /// instances of the dual-view table. Same deterministic first-choice policy as + /// the router's D3 case 3 (and the same ConsistentHashServerInstanceSelector + /// upgrade path); no Active instance resolves to an empty string, matching the + /// Aspire resolver contract. + /// + /// 目标服务名(动态拓扑下即 Role 名)/ The target service name (the role name in the dynamic topology) + /// 端点地址字符串(scheme://host:port);未找到时返回空字符串 / The endpoint string, or an empty string when not found + public string ResolveTcpEndpoint(string serviceName) + { + var activeInstances = _tableProvider.Current.GetActiveInstances(serviceName); + if (activeInstances.Count == 0) + { + return string.Empty; + } + + return activeInstances[0].AdvertiseEndpoint; + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoEndpointRegistry.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoEndpointRegistry.cs new file mode 100644 index 000000000..818cbe4b1 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoEndpointRegistry.cs @@ -0,0 +1,483 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using System.Net; +using System.Net.Sockets; +using MongoDB.Driver; + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// Mongo 心跳写侧(C143d D11/D15 / D17 通道 1 与通道 4)。 +/// +/// +/// The Mongo heartbeat writer (C143d D11/D15; D17 channels 1 and 4). +/// Owns this process's document in the control database server_heartbeat +/// collection: an immediate upsert on start, then a full-document upsert every +/// heartbeat interval (5 s default). The upsert is always the complete latest +/// state, so after a Mongo outage every field recovers with the next successful +/// write — the failure cache only needs to remember that writes are pending, +/// which is the "cache latest state, batch-resend on recovery" mitigation in +/// practice (D15 risks). Graceful exit writes Stopped (D17 channel 4) instead of +/// waiting for the TTL: once on and, as a best-effort +/// safety net, on — the full SIGTERM wiring +/// arrives with the compose signal handling of C143f. +/// +public sealed class MongoEndpointRegistry : IDisposable +{ + /// + /// 广播主机环境变量(D12:compose 模板生成;DNS 名一等缺省)。 + /// + /// + /// The advertise host environment variable (D12, generated by the compose template; DNS names are the first-class default). + /// + public const string AdvertiseHostEnvironmentVariable = "GameFrameX__AdvertiseHost"; + + /// + /// 广播端口环境变量(D12)。 + /// + /// + /// The advertise port environment variable (D12). + /// + public const string AdvertisePortEnvironmentVariable = "GameFrameX__AdvertisePort"; + + /// + /// 实例标识环境变量(D12;多实例同 Role 时用于稳定实例身份)。 + /// + /// + /// The role instance id environment variable (D12; keeps the instance identity stable across fixed-instance deployments). + /// + public const string RoleInstanceIdEnvironmentVariable = "GameFrameX__RoleInstanceId"; + + /// + /// 缺省心跳间隔(5s,D11)。 + /// + /// + /// The default heartbeat interval (5 s, D11). + /// + public static readonly TimeSpan DefaultHeartbeatInterval = TimeSpan.FromSeconds(5); + + /// + /// server_heartbeat 集合名(D18 全名约定;稳定契约,运维与测试可直接引用)。 + /// + /// + /// The server_heartbeat collection name (D18 no-abbreviation rule; a stable + /// contract, safe to reference from operations tooling and tests). + /// + public const string HeartbeatCollectionName = "server_heartbeat"; + + /// + /// TTL 索引保存时长(15s,D11:watcher 三周期阈值同值兜底)。 + /// + /// + /// The TTL expire-after window (15 s, D11; matches the watcher three-period threshold). + /// + private const long HeartbeatTimeToLiveSeconds = 15; + + /// + /// 心跳集合。 + /// + /// + /// The heartbeat collection. + /// + private readonly IMongoCollection _collection; + + /// + /// 本进程实例身份。 + /// + /// + /// This process's instance identity (role, instance id, endpoint, incarnation). + /// + private readonly InstanceDescriptor _selfDescriptor; + + /// + /// 心跳间隔。 + /// + /// + /// The heartbeat interval. + /// + private readonly TimeSpan _heartbeatInterval; + + /// + /// 心跳循环取消令牌源。 + /// + /// + /// The cancellation token source of the heartbeat loop. + /// + private readonly CancellationTokenSource _loopCancellation = new CancellationTokenSource(); + + /// + /// 心跳循环任务。 + /// + /// + /// The heartbeat loop task. + /// + private Task _loopTask; + + /// + /// 是否已写终态 Stopped(幂等守卫)。 + /// + /// + /// Whether the terminal Stopped state was already written (idempotent guard — + /// only Stopped writes are deduplicated; heartbeats keep flowing until then). + /// + private int _stoppedWritten; + + /// + /// 当前宣告的实例状态(启动完成为 Booting,MarkActive 后为 Active)。 + /// + /// + /// The status currently announced by the heartbeat loop: Booting until the + /// owning startup flow calls , Active afterwards. + /// + private volatile int _currentStatus = (int)InstanceStatus.Booting; + + /// + /// 初始化 Mongo 心跳写侧。 + /// + /// + /// Initializes the writer. Call to begin heartbeating. + /// + /// 控制库(gameframex_control)/ The control database + /// 本进程实例身份 / This process's instance identity + /// 心跳间隔;缺省 5s / The heartbeat interval; defaults to 5 s + public MongoEndpointRegistry(IMongoDatabase controlDatabase, InstanceDescriptor selfDescriptor, TimeSpan? heartbeatInterval = null) + { + ArgumentNullException.ThrowIfNull(controlDatabase, nameof(controlDatabase)); + ArgumentNullException.ThrowIfNull(selfDescriptor, nameof(selfDescriptor)); + + _collection = controlDatabase.GetCollection(HeartbeatCollectionName); + _heartbeatInterval = heartbeatInterval ?? DefaultHeartbeatInterval; + _selfDescriptor = selfDescriptor; + } + + /// + /// 从环境变量构建本进程实例身份(D12 广播引导)。 + /// + /// + /// Builds this process's instance identity from the D12 bootstrap environment + /// variables. The advertise host resolution order: the explicit + /// first (a DNS name is the + /// first-class default), then egress-address detection (IPv4 preferred, IPv6 + /// tried next; a detection failure logs a warning and falls back to the machine + /// name, per the D15 risks mitigation). Returns null when the advertise port is + /// not configured — callers treat that as "no cross-process identity" and skip + /// the write side (single-process local development). + /// + /// 承载的 Role 名 / The hosted role name + /// 实例身份;未配置广播端口时为 null / The identity, or null when the advertise port is not configured + public static InstanceDescriptor CreateSelfDescriptorFromEnvironment(string roleName) + { + var advertisePortText = Environment.GetEnvironmentVariable(AdvertisePortEnvironmentVariable); + if (string.IsNullOrWhiteSpace(advertisePortText) || !int.TryParse(advertisePortText, out var advertisePort) || advertisePort < 1 || advertisePort > 65535) + { + return null; + } + + var advertiseHostText = Environment.GetEnvironmentVariable(AdvertiseHostEnvironmentVariable); + string advertiseHost; + EndpointAddressKind addressKind; + if (!string.IsNullOrWhiteSpace(advertiseHostText)) + { + advertiseHost = advertiseHostText.Trim(); + addressKind = EndpointParser.Parse($"tcp://{advertiseHost}:{advertisePort}").AddressKind; + } + else + { + (advertiseHost, addressKind) = DetectEgressAddress(); + } + + var instanceId = Environment.GetEnvironmentVariable(RoleInstanceIdEnvironmentVariable); + if (string.IsNullOrWhiteSpace(instanceId)) + { + instanceId = $"{roleName}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds():x}-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + } + + var incarnation = DateTimeOffset.UtcNow.UtcTicks; + // URI authority 中的 IPv6 host 必须方括号包裹:出口探测返回裸 IPv6 文本,显式配置且已带方括号的输入原样保留。 + var authorityHost = addressKind == EndpointAddressKind.IPv6 && !advertiseHost.StartsWith("[", StringComparison.Ordinal) + ? $"[{advertiseHost}]" + : advertiseHost; + return new InstanceDescriptor(roleName, instanceId.Trim(), $"tcp://{authorityHost}:{advertisePort}", InstanceStatus.Booting, 0, addressKind, incarnation, DateTime.UtcNow); + } + + /// + /// 启动心跳写侧:建 TTL 索引 + 立即 upsert 当前状态(Booting)+ 起后台心跳循环。 + /// + /// + /// Starts the writer: creates the TTL index (idempotent), upserts the current + /// status (Booting) immediately so the topology can see this process within one + /// poll round — without routing to it yet — then runs the background heartbeat + /// loop. The owning startup flow must call once + /// the process is truly ready (databases, components, listeners up) to flip + /// the announced status to Active. Also subscribes to + /// as the best-effort Stopped safety net. + /// + /// 取消令牌 / The cancellation token + /// 异步任务 / Async task + public async Task StartAsync(CancellationToken cancellationToken = default) + { + var indexKeys = Builders.IndexKeys.Ascending(document => document.LastHeartbeat); + var indexOptions = new CreateIndexOptions { ExpireAfter = TimeSpan.FromSeconds(HeartbeatTimeToLiveSeconds), Name = "lastHeartbeat_ttl_15s" }; + await _collection.Indexes.CreateOneAsync(new CreateIndexModel(indexKeys, indexOptions), cancellationToken: cancellationToken); + await UpsertHeartbeatAsync((InstanceStatus)_currentStatus, CancellationToken.None); + AppDomain.CurrentDomain.ProcessExit += OnProcessExit; + _loopTask = Task.Run(() => HeartbeatLoopAsync(_loopCancellation.Token)); + } + + /// + /// 标记实例就绪:心跳状态由 Booting 切换为 Active 并立即写入。 + /// + /// + /// Marks the instance as ready: flips the announced status from Booting to + /// Active and writes it immediately (instead of waiting for the next loop + /// tick), so other processes only discover this instance as routable once + /// its databases, components, and listeners are up. Repeat calls are + /// harmless (they just re-upsert Active). + /// + /// 取消令牌 / The cancellation token + /// 异步任务 / Async task + public Task MarkActiveAsync(CancellationToken cancellationToken = default) + { + _currentStatus = (int)InstanceStatus.Active; + return UpsertHeartbeatAsync(InstanceStatus.Active, cancellationToken); + } + + /// + /// 停止心跳写侧并写终态 Stopped(D17 通道 4 优雅退出)。 + /// + /// + /// Stops the loop and writes the terminal Stopped state so watchers drop this + /// instance immediately instead of waiting for the TTL. Idempotent. + /// + /// 异步任务 / Async task + public async Task StopAsync() + { + _loopCancellation.Cancel(); + if (_loopTask != null) + { + try + { + await _loopTask; + } + catch (OperationCanceledException) + { + } + } + + await WriteStoppedAsync(); + AppDomain.CurrentDomain.ProcessExit -= OnProcessExit; + } + + /// + /// 释放资源(未写终态时尽力同步写 Stopped)。 + /// + /// + /// Disposes and, when Stopped was not written yet, writes it synchronously as a best effort. + /// + public void Dispose() + { + try + { + _loopCancellation.Cancel(); + WriteStoppedAsync().GetAwaiter().GetResult(); + } + catch (Exception) + { + // 尽力而为:进程退出路径上 Mongo 不可达时无法补写终态,交给 TTL 兜底清除。 + } + finally + { + _loopCancellation.Dispose(); + } + } + + /// + /// 心跳循环:固定间隔全量 upsert。 + /// + /// + /// The heartbeat loop: a full-document upsert every interval. Mongo-outage + /// periods leave the document stale (the watcher marks the instance Offline + /// after the three-period threshold — the designed D15 risk behavior); recovery + /// is simply the next successful full upsert, which carries the complete + /// latest state, so no queued per-field replay is needed. + /// + /// 取消令牌 / The cancellation token + /// 异步任务 / Async task + private async Task HeartbeatLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(_heartbeatInterval, cancellationToken); + await UpsertHeartbeatAsync((InstanceStatus)_currentStatus, CancellationToken.None); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception exception) + { + LogHelper.Error(exception, "[MongoEndpointRegistry] heartbeat upsert failed for instance {instanceId}; will retry next interval", _selfDescriptor.InstanceId); + } + } + } + + /// + /// 进程退出钩子:尽力同步写 Stopped。 + /// + /// + /// The ProcessExit hook: writes Stopped synchronously with a short timeout. + /// + /// 事件源 / The event source + /// 事件参数 / The event arguments + private void OnProcessExit(object sender, EventArgs eventArguments) + { + try + { + WriteStoppedAsync().GetAwaiter().GetResult(); + } + catch (Exception) + { + // 进程退出路径上 Mongo 不可达时无法补写终态,交给 TTL 兜底清除。 + } + } + + /// + /// 写终态 Stopped(幂等)。 + /// + /// + /// Writes the terminal Stopped state (idempotent; the first writer wins). + /// + /// 异步任务 / Async task + private Task WriteStoppedAsync() + { + return UpsertHeartbeatAsync(InstanceStatus.Stopped, CancellationToken.None); + } + + /// + /// 全量 upsert 本进程心跳文档。 + /// + /// + /// Upserts the full heartbeat document (every field, always the latest state). + /// + /// 本次写入状态 / The status to write + /// 取消令牌 / The cancellation token + /// 异步任务 / Async task + private Task UpsertHeartbeatAsync(InstanceStatus status, CancellationToken cancellationToken) + { + if (status == InstanceStatus.Stopped) + { + // 终态幂等:首个 Stopped 写入胜出,后续 Stopped 调用直接返回。 + if (Interlocked.Exchange(ref _stoppedWritten, 1) != 0) + { + return Task.CompletedTask; + } + } + else if (Volatile.Read(ref _stoppedWritten) != 0) + { + // 终态后不再回退:Stopped 已写入时,仍在途的心跳(Booting/Active)不再覆盖终态。 + return Task.CompletedTask; + } + + var document = new ServerHeartbeatDocument( + _selfDescriptor.InstanceId, + _selfDescriptor.Role, + _selfDescriptor.AdvertiseEndpoint, + status.ToString(), + 0, // 负载自报:固定 0,接真实指标源属后续 change(ponytail:升级路径 = 注入负载采样回调)。 + _selfDescriptor.AddressKind.ToString(), + _selfDescriptor.Incarnation, + DateTime.UtcNow); + return _collection.ReplaceOneAsync( + Builders.Filter.Eq(candidate => candidate.InstanceId, _selfDescriptor.InstanceId), + document, + new ReplaceOptions { IsUpsert = true }, + cancellationToken); + } + + /// + /// 探测出口地址(IPv4 优先,失败回退 IPv6/本机名)。 + /// + /// + /// Detects the egress address: a UDP socket connect (no packet sent) to a public + /// address yields the IPv4 egress; on failure the host's resolved addresses are + /// scanned (IPv4 first, then IPv6); the machine name is the last resort, logged + /// as a warning per the D15 risks mitigation. + /// + /// 出口主机与形态 / The egress host and its address kind + private static (string Host, EndpointAddressKind AddressKind) DetectEgressAddress() + { + try + { + using (var probeSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)) + { + probeSocket.Connect("8.8.8.8", 65530); + if (probeSocket.LocalEndPoint is IPEndPoint ipv4EndPoint && !string.IsNullOrEmpty(ipv4EndPoint.Address.ToString())) + { + return (ipv4EndPoint.Address.ToString(), EndpointAddressKind.IPv4); + } + } + } + catch (Exception) + { + // 无外网路由(隔离网络/CI):走主机地址表回退。 + } + + try + { + var hostAddresses = Dns.GetHostAddresses(Dns.GetHostName()); + foreach (var address in hostAddresses) + { + if (address.AddressFamily == AddressFamily.InterNetwork) + { + return (address.ToString(), EndpointAddressKind.IPv4); + } + } + + foreach (var address in hostAddresses) + { + if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + return (address.ToString(), EndpointAddressKind.IPv6); + } + } + } + catch (Exception) + { + // 主机名解析失败:最后回退本机名。 + } + + var machineName = Dns.GetHostName(); + LogHelper.Warning("[MongoEndpointRegistry] egress address detection failed; falling back to the machine name {machineName} as the advertise host. Set {environmentVariable} explicitly when cross-process routing is required.", machineName, AdvertiseHostEnvironmentVariable); + return (machineName, EndpointAddressKind.DnsName); + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoEndpointWatcher.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoEndpointWatcher.cs new file mode 100644 index 000000000..6552a0dad --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/MongoEndpointWatcher.cs @@ -0,0 +1,489 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using MongoDB.Driver; + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// Mongo 心跳读侧(C143d D11/D15:判活 + 双视图路由表 + 事件)。 +/// +/// +/// The Mongo heartbeat reader (C143d D11/D15: liveness + the dual-view route table + events). +/// Polls the control database server_heartbeat collection every interval (5 s default) +/// without mutating it, judges each instance against the three-period staleness threshold +/// (15 s by default — the primary liveness signal, with the Mongo TTL as the last-resort +/// document cleanup), and rebuilds the immutable snapshot +/// atomically (Interlocked.Exchange — D15). Every shape change is broadcast to +/// subscribers: Online / Draining / Offline / Evicted / +/// Recovered, with the incarnation rule of D15 — the same instance id coming back with a +/// different incarnation emits Offline+Online, never Recovered. During a Mongo outage the +/// previous table is served unchanged (the partition risk mitigation of D15). +/// +public sealed class MongoEndpointWatcher : IRoleRouteTableProvider, IDisposable +{ + /// + /// 缺省轮询间隔(5s,D11)。 + /// + /// + /// The default poll interval (5 s, D11). + /// + public static readonly TimeSpan DefaultPollInterval = TimeSpan.FromSeconds(5); + + /// + /// 缺省陈旧阈值周期数(3 个心跳周期,D11/D15)。 + /// + /// + /// The default staleness threshold in heartbeat periods (3, D11/D15). + /// + public const int DefaultStalenessPeriods = 3; + + /// + /// 心跳集合。 + /// + /// + /// The heartbeat collection. + /// + private readonly IMongoCollection _collection; + + /// + /// 轮询间隔。 + /// + /// + /// The poll interval. + /// + private readonly TimeSpan _pollInterval; + + /// + /// 判活阈值(lastHeartbeat 超过此时长视为陈旧)。 + /// + /// + /// The staleness threshold: a lastHeartbeat older than this marks the instance stale. + /// + private readonly TimeSpan _stalenessThreshold; + + /// + /// 事件订阅者列表(启动后只读快照,订阅在 Start 前完成)。 + /// + /// + /// The event subscribers (a read-only snapshot once started; subscribe before Start). + /// + private readonly List _subscribers = new List(); + + /// + /// 订阅者列表的同步锁。 + /// + /// + /// The subscribers list lock. + /// + private readonly object _subscribersLock = new object(); + + /// + /// 当前已知实例(instanceId → 最近观测与其陈旧标记;含 stale 待 Evicted 项)。 + /// + /// + /// The currently known instances (instance id to the last observation and its stale flag; + /// stale entries stay until the TTL removes their documents, so Recovered can be detected). + /// + private readonly Dictionary _knownInstances = new Dictionary(StringComparer.Ordinal); + + /// + /// 曾观测过的最后代数(instanceId → incarnation;Evicted 后仍保留,供重启判定)。 + /// + /// + /// The last incarnation ever observed per instance id (kept after eviction so a restart + /// under the same id can be distinguished from a recovery). + /// + private readonly Dictionary _lastSeenIncarnations = new Dictionary(StringComparer.Ordinal); + + /// + /// 轮询循环取消令牌源。 + /// + /// + /// The poll loop cancellation token source. + /// + private readonly CancellationTokenSource _loopCancellation = new CancellationTokenSource(); + + /// + /// 状态与快照的同步锁(单轮 poll 串行化)。 + /// + /// + /// The lock serializing state transitions and snapshot swaps. + /// + private readonly object _stateLock = new object(); + + /// + /// 轮询循环任务。 + /// + /// + /// The poll loop task. + /// + private Task _loopTask; + + /// + /// 当前双视图路由表快照(D15:volatile 原子替换不可变快照)。 + /// + /// + /// The current dual-view snapshot (D15: volatile write of an immutable snapshot, + /// so lock-free readers always observe a fully-built table). + /// + private volatile RoleRouteTable _currentTable = RoleRouteTable.Empty; + + /// + /// 初始化 Mongo 心跳读侧。 + /// + /// + /// Initializes the watcher. Call to begin polling. + /// + /// 控制库(gameframex_control)/ The control database + /// 轮询间隔;缺省 5s / The poll interval; defaults to 5 s + /// 判活阈值;缺省 3 × 轮询间隔(15s)/ The staleness threshold; defaults to 3 × the poll interval (15 s) + public MongoEndpointWatcher(IMongoDatabase controlDatabase, TimeSpan? pollInterval = null, TimeSpan? stalenessThreshold = null) + { + ArgumentNullException.ThrowIfNull(controlDatabase, nameof(controlDatabase)); + + _collection = controlDatabase.GetCollection(MongoEndpointRegistry.HeartbeatCollectionName); + _pollInterval = pollInterval ?? DefaultPollInterval; + _stalenessThreshold = stalenessThreshold ?? TimeSpan.FromTicks(_pollInterval.Ticks * DefaultStalenessPeriods); + } + + /// + /// 获取当前双视图路由表快照(D15 原子替换语义)。 + /// + /// + /// Gets the current snapshot (D15 atomic-replacement semantics); never null. + /// + /// 当前快照 / The current snapshot + public RoleRouteTable Current + { + get + { + return _currentTable; + } + } + + /// + /// 订阅实例上下线事件(须在 之前调用)。 + /// + /// + /// Subscribes to instance lifecycle events (must be called before + /// so no event can be missed between subscribing and the first poll). + /// + /// 事件订阅者 / The subscriber + public void Subscribe(IRoleInstanceEvents events) + { + ArgumentNullException.ThrowIfNull(events, nameof(events)); + + lock (_subscribersLock) + { + _subscribers.Add(events); + } + } + + /// + /// 启动读侧:立即执行一轮轮询 + 起后台轮询循环。 + /// + /// + /// Starts the watcher: one immediate poll round (so callers get a non-empty table + /// as soon as StartAsync returns) followed by the background loop. + /// + /// 取消令牌 / The cancellation token + /// 异步任务 / Async task + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await PollOnceAsync(cancellationToken); + _loopTask = Task.Run(() => PollLoopAsync(_loopCancellation.Token)); + } + + /// + /// 停止读侧轮询。 + /// + /// + /// Stops the poll loop and waits for it; the last snapshot stays readable through . + /// + /// 异步任务 / Async task + public async Task StopAsync() + { + _loopCancellation.Cancel(); + if (_loopTask != null) + { + try + { + await _loopTask; + } + catch (OperationCanceledException) + { + } + } + } + + /// + /// 释放资源。 + /// + /// + /// Releases resources (cancels the loop). + /// + public void Dispose() + { + _loopCancellation.Cancel(); + _loopCancellation.Dispose(); + } + + /// + /// 轮询循环。 + /// + /// + /// The poll loop. A failed round keeps the previous snapshot (Mongo outage resilience) + /// and retries on the next tick. + /// + /// 取消令牌 / The cancellation token + /// 异步任务 / Async task + private async Task PollLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(_pollInterval, cancellationToken); + await PollOnceAsync(CancellationToken.None); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception exception) + { + LogHelper.Error(exception, "[MongoEndpointWatcher] poll round failed; keeping the previous route table and retrying next interval"); + } + } + } + + /// + /// 执行一轮轮询:拉全量 → 判活 → 状态转移 → 原子替换快照 → 广播事件。 + /// + /// + /// One poll round: fetch every document, judge liveness, apply the state machine + /// (Online / Draining / Offline / Evicted / Recovered, with the incarnation rule), + /// swap the snapshot atomically, then broadcast the collected events. + /// + /// 取消令牌 / The cancellation token + /// 异步任务 / Async task + private async Task PollOnceAsync(CancellationToken cancellationToken) + { + var documents = await _collection.Find(FilterDefinition.Empty).ToListAsync(cancellationToken); + var pendingEvents = new List>(); + List liveInstances; + + lock (_stateLock) + { + liveInstances = ApplyStateTransitions(documents, DateTime.UtcNow, pendingEvents); + _currentTable = RoleRouteTable.FromInstances(liveInstances); + } + + // 先换表后广播:订阅者在事件里读到的 Current 已是转移后的新表。 + IRoleInstanceEvents[] subscribersSnapshot; + lock (_subscribersLock) + { + subscribersSnapshot = _subscribers.ToArray(); + } + + foreach (var pair in pendingEvents) + { + foreach (var subscriber in subscribersSnapshot) + { + subscriber.OnInstanceChanged(pair.Key, pair.Value); + } + } + } + + /// + /// 应用状态机并产出本轮存活实例集合。 + /// + /// + /// Applies the state machine (under the state lock) and returns the live instance set + /// for the new snapshot. Events are collected instead of fired inline so the snapshot + /// can be swapped before any subscriber runs. + /// + /// 本轮拉取的心跳文档 / The documents fetched this round + /// 判定基准时间(UTC)/ The judgement reference time (UTC) + /// 收集的事件 / The collected events + /// 存活实例集合 / The live instance set + private List ApplyStateTransitions(List documents, DateTime nowUtc, List> pendingEvents) + { + var liveInstances = new List(documents.Count); + var observedInstanceIds = new HashSet(StringComparer.Ordinal); + foreach (var document in documents) + { + var descriptor = TryToDescriptor(document); + if (descriptor == null) + { + continue; + } + + observedInstanceIds.Add(descriptor.InstanceId); + var isStale = nowUtc - descriptor.LastHeartbeatUtc > _stalenessThreshold; + _lastSeenIncarnations.TryGetValue(descriptor.InstanceId, out var lastIncarnation); + + if (!_knownInstances.TryGetValue(descriptor.InstanceId, out var known)) + { + // 新出现的 instanceId:仅对具备路由资格(非 stale 且 Active/Draining,与路由表准入一致)的首次观测发事件; + // stale 或 Stopped/Booting 首次观测不进路由表,也不发 Online,避免订阅者看到表中不存在的实例。 + if (!isStale && (descriptor.Status == InstanceStatus.Active || descriptor.Status == InstanceStatus.Draining)) + { + if (lastIncarnation != default && lastIncarnation != descriptor.Incarnation) + { + // 曾在 graveyard 里见过且 incarnation 变化 → 重启语义(Offline+Online,D15 规则)。 + pendingEvents.Add(new KeyValuePair(RoleInstanceChangeKind.Offline, descriptor)); + pendingEvents.Add(new KeyValuePair(RoleInstanceChangeKind.Online, descriptor)); + } + else if (descriptor.Status == InstanceStatus.Draining) + { + // 首次观测即为 Draining:发 Draining(不接新流量、保留在途投递)。 + pendingEvents.Add(new KeyValuePair(RoleInstanceChangeKind.Draining, descriptor)); + } + else + { + pendingEvents.Add(new KeyValuePair(RoleInstanceChangeKind.Online, descriptor)); + } + } + } + else if (known.Descriptor.Incarnation != descriptor.Incarnation) + { + // 已知实例的 incarnation 变化:旧身份下线 + 新身份上线(D15 incarnation 规则)。 + pendingEvents.Add(new KeyValuePair(RoleInstanceChangeKind.Offline, known.Descriptor)); + pendingEvents.Add(new KeyValuePair(RoleInstanceChangeKind.Online, descriptor)); + } + else if (known.IsStale && !isStale) + { + // 同 incarnation 从陈旧恢复新鲜 → Recovered。 + pendingEvents.Add(new KeyValuePair(RoleInstanceChangeKind.Recovered, descriptor)); + } + else if (!known.IsStale && known.Descriptor.Status != descriptor.Status && !isStale) + { + // 状态跃迁:→ Draining 发 Draining;→ Active(自 Draining 恢复接流)发 Online;→ Stopped 发 Offline。 + if (descriptor.Status == InstanceStatus.Draining) + { + pendingEvents.Add(new KeyValuePair(RoleInstanceChangeKind.Draining, descriptor)); + } + else if (descriptor.Status == InstanceStatus.Active) + { + pendingEvents.Add(new KeyValuePair(RoleInstanceChangeKind.Online, descriptor)); + } + else if (descriptor.Status == InstanceStatus.Stopped) + { + pendingEvents.Add(new KeyValuePair(RoleInstanceChangeKind.Offline, descriptor)); + } + } + else if (!known.IsStale && isStale) + { + // 新鲜 → 陈旧:三周期阈值判死,摘出路由表。 + pendingEvents.Add(new KeyValuePair(RoleInstanceChangeKind.Offline, descriptor)); + } + + _knownInstances[descriptor.InstanceId] = new KnownInstance(descriptor, isStale); + _lastSeenIncarnations[descriptor.InstanceId] = descriptor.Incarnation; + if (!isStale && descriptor.Status != InstanceStatus.Stopped) + { + liveInstances.Add(descriptor); + } + } + + // 曾知实例本轮文档消失(TTL 已清除)→ Evicted,彻底移出观测。 + var evictedIds = new List(); + foreach (var pair in _knownInstances) + { + if (!observedInstanceIds.Contains(pair.Key)) + { + evictedIds.Add(pair.Key); + pendingEvents.Add(new KeyValuePair(RoleInstanceChangeKind.Evicted, pair.Value.Descriptor)); + } + } + + foreach (var evictedId in evictedIds) + { + _knownInstances.Remove(evictedId); + } + + return liveInstances; + } + + /// + /// 心跳文档 → 实例描述符(未知枚举名返回 null 防御旧版本文档)。 + /// + /// + /// Converts a heartbeat document to a descriptor; returns null on an unknown enum + /// name or empty endpoint (defensive against documents written by a different version). + /// + /// 心跳文档 / The heartbeat document + /// 实例描述符;无法转换时为 null / The descriptor, or null when unparsable + private static InstanceDescriptor TryToDescriptor(ServerHeartbeatDocument document) + { + if (string.IsNullOrWhiteSpace(document.InstanceId) || string.IsNullOrWhiteSpace(document.Role) || string.IsNullOrWhiteSpace(document.AdvertiseEndpoint)) + { + return null; + } + + if (!Enum.TryParse(document.Status, false, out var status) || !Enum.TryParse(document.AddressKind, false, out var addressKind)) + { + return null; + } + + return new InstanceDescriptor(document.Role, document.InstanceId, document.AdvertiseEndpoint, status, document.Load, addressKind, document.Incarnation, document.LastHeartbeat); + } + + /// + /// 已知实例观测记录(描述符 + 陈旧标记)。 + /// + /// + /// The record of a known instance: its last observed descriptor and the stale flag. + /// + private sealed class KnownInstance + { + /// + /// 初始化观测记录。 + /// + /// + /// Initializes the record. + /// + /// 最近观测 / The last observed descriptor + /// 是否陈旧 / Whether the observation is stale + public KnownInstance(InstanceDescriptor descriptor, bool isStale) + { + Descriptor = descriptor; + IsStale = isStale; + } + + /// 最近观测 / The last observed descriptor + public InstanceDescriptor Descriptor { get; } + + /// 是否陈旧 / The stale flag + public bool IsStale { get; } + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/ParsedEndpoint.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/ParsedEndpoint.cs new file mode 100644 index 000000000..e6a9fff68 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/ParsedEndpoint.cs @@ -0,0 +1,109 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// 解析后的端点三元组(C143d D15:scheme / host / port)。 +/// +/// +/// The parsed endpoint triple (C143d D15: scheme / host / port). +/// Endpoints are stored unparsed everywhere and parsed only at connect time +/// (design source D15 "存储不解析、连接时才解析"); this type is the single +/// result shape of . The instance is immutable. +/// +public sealed class ParsedEndpoint +{ + /// + /// 初始化解析后的端点。 + /// + /// + /// Initializes the parsed endpoint. Use instead of + /// calling this constructor directly so every endpoint goes through the same validation. + /// + /// 协议 scheme(小写,如 tcp/kcp/ws/wss)/ The lower-cased scheme (e.g. tcp/kcp/ws/wss) + /// 主机(域名/容器名/Kubernetes Service 名/IPv4/IPv6 字面量,IPv6 不带方括号)/ The host (domain/container/Kubernetes Service name/IPv4/IPv6 literal without brackets) + /// 端口(1–65535)/ The port (1-65535) + /// 主机形态 / The host address kind + public ParsedEndpoint(string scheme, string host, int port, EndpointAddressKind addressKind) + { + Scheme = scheme; + Host = host; + Port = port; + AddressKind = addressKind; + } + + /// + /// 获取协议 scheme(小写)。 + /// + /// + /// Gets the lower-cased scheme. + /// + /// 协议 scheme / The scheme + public string Scheme { get; } + + /// + /// 获取主机(IPv6 字面量已去掉方括号)。 + /// + /// + /// Gets the host (IPv6 literals have their brackets stripped). + /// + /// 主机 / The host + public string Host { get; } + + /// + /// 获取端口。 + /// + /// + /// Gets the port. + /// + /// 端口 / The port + public int Port { get; } + + /// + /// 获取主机形态。 + /// + /// + /// Gets the host address kind. + /// + /// 主机形态 / The address kind + public EndpointAddressKind AddressKind { get; } + + /// + public override string ToString() + { + if (AddressKind == EndpointAddressKind.IPv6) + { + return $"{Scheme}://[{Host}]:{Port}"; + } + + return $"{Scheme}://{Host}:{Port}"; + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/RoleInstanceChangeKind.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/RoleInstanceChangeKind.cs new file mode 100644 index 000000000..92b824266 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/RoleInstanceChangeKind.cs @@ -0,0 +1,81 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// 实例上下线变化类别(C143d D15 事件)。 +/// +/// +/// The kind of instance lifecycle change broadcast by the watcher (C143d D15 events). +/// Values start at 1 on purpose: an uninitialized field must never read as a valid kind. +/// +public enum RoleInstanceChangeKind +{ + /// + /// 上线:新实例出现或重启实例(新 incarnation)上线。 + /// + /// + /// Online: a new instance appeared, or a restarted instance (new incarnation) came online. + /// + Online = 1, + + /// + /// 排水中:实例进入 Draining。 + /// + /// + /// Draining: the instance entered the Draining state. + /// + Draining = 2, + + /// + /// 离线:心跳超过三周期阈值,从路由表摘除。 + /// + /// + /// Offline: the heartbeat exceeded the three-period staleness threshold and was evicted from the table. + /// + Offline = 3, + + /// + /// 驱逐:心跳文档被 TTL 清除,实例身份彻底消失。 + /// + /// + /// Evicted: the heartbeat document was TTL-removed; the instance identity is gone for good. + /// + Evicted = 4, + + /// + /// 恢复:同一实例(incarnation 不变)心跳恢复新鲜。 + /// + /// + /// Recovered: the same instance (unchanged incarnation) went stale and then became fresh again. + /// + Recovered = 5, +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/RoleRouteTable.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/RoleRouteTable.cs new file mode 100644 index 000000000..430902e11 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/RoleRouteTable.cs @@ -0,0 +1,179 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// 双视图路由表快照(C143d D15:Role 视图 + Instance 视图)。 +/// +/// +/// The immutable dual-view route table snapshot (C143d D15). +/// The Role view serves D3 case 3 (any active instance of a role: only +/// instances — Draining is excluded from new +/// traffic); the Instance view serves D3 case 2 (a known instance: Active and +/// Draining both stay routable for in-flight deliveries). +/// The watcher replaces the whole snapshot atomically (Interlocked.Exchange); +/// readers never lock and never observe a partially-updated table. +/// +public sealed class RoleRouteTable +{ + /// + /// 空路由表(初始态)。 + /// + /// + /// The empty table (the initial state before the first poll completes). + /// + public static readonly RoleRouteTable Empty = new RoleRouteTable( + new Dictionary>(StringComparer.Ordinal), + new Dictionary(StringComparer.Ordinal)); + + /// + /// Role 视图:Role 名 → Active 实例列表。 + /// + /// + /// The Role view: role name to its Active instances (Draining excluded). + /// + private readonly IReadOnlyDictionary> _activeInstancesByRole; + + /// + /// Instance 视图:instanceId → 实例(Active + Draining)。 + /// + /// + /// The Instance view: instance id to its descriptor (Active and Draining). + /// + private readonly IReadOnlyDictionary _instancesById; + + /// + /// 初始化双视图路由表。 + /// + /// + /// Initializes the snapshot from the live instance set; both views are derived + /// in one pass so they can never disagree. + /// + /// Role → Active 实例列表 / Role to its Active instances + /// instanceId → 实例 / Instance id to its descriptor + private RoleRouteTable(IReadOnlyDictionary> activeInstancesByRole, IReadOnlyDictionary instancesById) + { + _activeInstancesByRole = activeInstancesByRole; + _instancesById = instancesById; + } + + /// + /// 从存活实例集合构建双视图快照。 + /// + /// + /// Builds a snapshot from the live instance set. Only Active instances enter + /// the Role view; the Instance view keeps Active and Draining; every other + /// status (Booting, Stopped, Removed, ...) is excluded from both views so no + /// not-yet-ready or decommissioned instance is ever routable. + /// + /// 本轮判活后的实例集合 / The instances judged live this round + /// 双视图快照 / The dual-view snapshot + public static RoleRouteTable FromInstances(IEnumerable liveInstances) + { + ArgumentNullException.ThrowIfNull(liveInstances, nameof(liveInstances)); + + var instancesById = new Dictionary(StringComparer.Ordinal); + var activeByRole = new Dictionary>(StringComparer.Ordinal); + foreach (var instance in liveInstances) + { + // Instance 视图仅收 Active/Draining(D3 case 2 契约):Booting/Removed 等其余状态不参与任何路由, + // 防止已注册但尚未就绪(或已摘除)的实例被 case 2 解析并转发流量。 + if (instance.Status == InstanceStatus.Active || instance.Status == InstanceStatus.Draining) + { + instancesById.Add(instance.InstanceId, instance); + } + + if (instance.Status == InstanceStatus.Active) + { + if (!activeByRole.TryGetValue(instance.Role, out var instances)) + { + instances = new List(); + activeByRole[instance.Role] = instances; + } + + instances.Add(instance); + } + } + + var activeView = new Dictionary>(activeByRole.Count, StringComparer.Ordinal); + foreach (var pair in activeByRole) + { + activeView.Add(pair.Key, pair.Value.AsReadOnly()); + } + + return new RoleRouteTable(activeView, instancesById); + } + + /// + /// 按实例 Id 查找实例(D3 case 2)。 + /// + /// + /// Resolves an instance by id (D3 case 2). Draining instances resolve on purpose: + /// in-flight deliveries to a known draining instance are still valid. + /// + /// 实例 Id / The instance id + /// 实例描述符;未找到时为 null / The descriptor, or null when absent + /// 找到返回 true;否则 false / true when found; otherwise false + public bool TryGetInstance(string instanceId, out InstanceDescriptor instance) + { + return _instancesById.TryGetValue(instanceId, out instance); + } + + /// + /// 获取指定 Role 的 Active 实例列表(D3 case 3)。 + /// + /// + /// Gets the Active instances of a role (D3 case 3); Draining is excluded. + /// + /// Role 名 / The role name + /// Active 实例只读列表;无实例时为空列表 / The read-only Active instance list, or an empty list + public IReadOnlyList GetActiveInstances(string roleName) + { + if (_activeInstancesByRole.TryGetValue(roleName, out var instances)) + { + return instances; + } + + return Array.Empty(); + } + + /// + /// 获取全部实例(Instance 视图值集合)。 + /// + /// + /// Gets every instance in the Instance view. + /// + /// 全部实例 / All instances + public IReadOnlyCollection GetAllInstances() + { + return (IReadOnlyCollection)_instancesById.Values; + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Discovery/ServerHeartbeatDocument.cs b/GameFrameX.NetWork.RemoteMessaging/Discovery/ServerHeartbeatDocument.cs new file mode 100644 index 000000000..4ee6fafa1 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Discovery/ServerHeartbeatDocument.cs @@ -0,0 +1,147 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace GameFrameX.NetWork.RemoteMessaging.Discovery; + +/// +/// server_heartbeat 集合文档模型(C143d D11/D15,D18 全名约定)。 +/// +/// +/// The document model of the server_heartbeat collection in the control +/// database (C143d D11/D15; the collection name follows the D18 no-abbreviation rule). +/// One document per live instance, keyed by instance id; the TTL index on +/// (15 s) is the last-resort cleanup for instances that +/// died without writing Stopped, while the watcher's three-period staleness check +/// remains the primary liveness signal. +/// +internal sealed class ServerHeartbeatDocument +{ + /// + /// 初始化心跳文档。 + /// + /// + /// Initializes the document. + /// + /// 实例唯一标识(文档主键)/ The unique instance id (the document key) + /// 承载的 Role 名 / The hosted role name + /// 对外可达端点(未解析)/ The reachable endpoint (unparsed) + /// 实例状态名 / The instance status name + /// 负载值(0–100)/ The load value (0-100) + /// 地址形态名 / The address kind name + /// 代数(重启纪元)/ The incarnation + /// 本次心跳时间(UTC)/ This heartbeat time (UTC) + public ServerHeartbeatDocument(string instanceId, string role, string advertiseEndpoint, string status, int load, string addressKind, long incarnation, DateTime lastHeartbeatUtc) + { + InstanceId = instanceId; + Role = role; + AdvertiseEndpoint = advertiseEndpoint; + Status = status; + Load = load; + AddressKind = addressKind; + Incarnation = incarnation; + LastHeartbeat = lastHeartbeatUtc; + } + + /// + /// 获取或设置实例唯一标识(主键)。 + /// + /// + /// Gets or sets the instance id (the primary key). + /// + [BsonId] + public string InstanceId { get; set; } + + /// + /// 获取或设置承载的 Role 名。 + /// + /// + /// Gets or sets the hosted role name. + /// + [BsonElement("role")] + public string Role { get; set; } + + /// + /// 获取或设置对外可达端点(未解析的 scheme://host:port)。 + /// + /// + /// Gets or sets the advertise endpoint (unparsed scheme://host:port, D15). + /// + [BsonElement("advertiseEndpoint")] + public string AdvertiseEndpoint { get; set; } + + /// + /// 获取或设置实例状态名。 + /// + /// + /// Gets or sets the status name (the enum name, stored as a string for readability). + /// + [BsonElement("status")] + public string Status { get; set; } + + /// + /// 获取或设置负载值(0–100)。 + /// + /// + /// Gets or sets the load value (0-100). + /// + [BsonElement("load")] + public int Load { get; set; } + + /// + /// 获取或设置地址形态名。 + /// + /// + /// Gets or sets the address kind name. + /// + [BsonElement("addressKind")] + public string AddressKind { get; set; } + + /// + /// 获取或设置代数(重启纪元)。 + /// + /// + /// Gets or sets the incarnation. + /// + [BsonElement("incarnation")] + public long Incarnation { get; set; } + + /// + /// 获取或设置最后心跳时间(UTC,TTL 索引字段)。 + /// + /// + /// Gets or sets the last heartbeat time (UTC; the TTL index field). + /// + [BsonElement("lastHeartbeat")] + [BsonRepresentation(BsonType.DateTime)] + public DateTime LastHeartbeat { get; set; } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/GameFrameX.NetWork.RemoteMessaging.csproj b/GameFrameX.NetWork.RemoteMessaging/GameFrameX.NetWork.RemoteMessaging.csproj index abb109b24..7a2a0b092 100644 --- a/GameFrameX.NetWork.RemoteMessaging/GameFrameX.NetWork.RemoteMessaging.csproj +++ b/GameFrameX.NetWork.RemoteMessaging/GameFrameX.NetWork.RemoteMessaging.csproj @@ -12,4 +12,9 @@ + + + + + diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/IEnvelopeForwarder.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/IEnvelopeForwarder.cs new file mode 100644 index 000000000..c1a9277a0 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/IEnvelopeForwarder.cs @@ -0,0 +1,61 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Discovery; + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 跨进程信封转发缝(C143d D3 case 2/3 的发送通道)。 +/// +/// +/// The send channel behind the D3 case 2/3 remote forwarding seam (C143d). +/// The router resolves where (instance selection + endpoint parsing); the +/// forwarder moves the envelope there. Keeping the transport behind this +/// narrow seam lets the routing logic be unit-tested with a fake forwarder and lets +/// the transport evolve (TCP now; KCP/QUIC via the same seam later) without +/// touching the decision code. +/// +public interface IEnvelopeForwarder +{ + /// + /// 将路由信封转发到已解析的目标端点。 + /// + /// + /// Forwards the routing envelope to the already-parsed target endpoint. + /// Transport failures propagate to the caller — forwarding must fail loudly + /// (never a silent drop); retry/circuit policies belong to upper layers. + /// + /// 已解析的目标端点 / The parsed target endpoint + /// 路由信封 / The routing envelope + /// 取消令牌 / The cancellation token + /// 异步任务;失败时抛出 / Async task; throws on transport failure + Task ForwardAsync(ParsedEndpoint endpoint, MessageEnvelope envelope, CancellationToken cancellationToken = default); +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/MongoDiscoveryRemoteRoleRouter.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/MongoDiscoveryRemoteRoleRouter.cs new file mode 100644 index 000000000..adccb2d4f --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/MongoDiscoveryRemoteRoleRouter.cs @@ -0,0 +1,150 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Discovery; + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 基于 Mongo 发现层的跨进程 Role 转发器(C143d D3 case 2/3 真实实现)。 +/// +/// +/// The real D3 case 2/3 implementation consuming the MongoEndpointWatcher dual-view +/// route table (C143d; it replaces the C143c placeholder that always threw). +/// Case 2 resolves the envelope's target instance id through the Instance view +/// (Draining stays resolvable — in-flight deliveries remain valid during graceful +/// scale-down); case 3 picks from the Role view's Active instances (Draining +/// excluded — no new traffic) with a deterministic first-choice policy — +/// ponytail: upgrade path is the existing ConsistentHashServerInstanceSelector +/// once per-key affinity is needed. Both failures are loud +/// s, matching the C143c seam contract of +/// never silently dropping a message. +/// +public sealed class MongoDiscoveryRemoteRoleRouter : IRemoteRoleRouter +{ + /// + /// 双视图路由表提供者。 + /// + /// + /// The dual-view route table provider (the watcher in production). + /// + private readonly IRoleRouteTableProvider _tableProvider; + + /// + /// 信封转发缝(发送通道)。 + /// + /// + /// The envelope forwarding seam (the send channel). + /// + private readonly IEnvelopeForwarder _forwarder; + + /// + /// 初始化跨进程 Role 转发器。 + /// + /// + /// Initializes the router with the table provider and the send channel. + /// + /// 双视图路由表提供者 / The dual-view route table provider + /// 信封转发缝 / The envelope forwarding seam + public MongoDiscoveryRemoteRoleRouter(IRoleRouteTableProvider tableProvider, IEnvelopeForwarder forwarder) + { + ArgumentNullException.ThrowIfNull(tableProvider, nameof(tableProvider)); + ArgumentNullException.ThrowIfNull(forwarder, nameof(forwarder)); + + _tableProvider = tableProvider; + _forwarder = forwarder; + } + + /// + /// 将信封转发给目标 Role 所在的远端进程(D3 case 2/3)。 + /// + /// + /// Resolves the target instance through the dual-view table and forwards the + /// envelope through the send channel. An unresolvable target (unknown instance + /// id for case 2, or no Active instance of the role for case 3) throws + /// ; a transport failure propagates as-is. + /// + /// 路由信封(目标 Role 不属于本进程角色集)/ The routing envelope (target role is hosted by another process) + /// 取消令牌 / The cancellation token + /// 恒为 / Always + /// 为 null 时抛出 / Thrown when envelope is null + /// 当目标实例或目标 Role 的 Active 实例不存在时抛出 / Thrown when the target instance or the role's Active instances are absent + public async Task ForwardAsync(MessageEnvelope envelope, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(envelope, nameof(envelope)); + + var table = _tableProvider.Current; + var targetInstance = ResolveTargetInstance(table, envelope); + + // 把选中的实例 Id 盖到信封上:case 3 的选择结果对发送通道与接收端复投(C143e)都必须可见。 + var stampedEnvelope = envelope.TargetInstanceId == targetInstance.InstanceId + ? envelope + : new MessageEnvelope(envelope.TargetRole, envelope.Message, envelope.TargetActorId, targetInstance.InstanceId); + + var parsedEndpoint = EndpointParser.Parse(targetInstance.AdvertiseEndpoint); + await _forwarder.ForwardAsync(parsedEndpoint, stampedEnvelope, cancellationToken); + return RoleRouteDelivery.RemoteForwarded; + } + + /// + /// 解析目标实例(D3 case 2 按实例 Id / case 3 取 Active 首选)。 + /// + /// + /// Resolves the target instance: case 2 by the envelope's instance id, case 3 + /// deterministically from the role's Active instances. + /// + /// 当前双视图快照 / The current dual-view snapshot + /// 路由信封 / The routing envelope + /// 目标实例 / The resolved target instance + /// 当目标不可解析时抛出 / Thrown when the target cannot be resolved + private static InstanceDescriptor ResolveTargetInstance(RoleRouteTable table, MessageEnvelope envelope) + { + // D3 case 2:指定实例投递(Draining 实例仍可命中——在途投递合法)。 + if (!string.IsNullOrEmpty(envelope.TargetInstanceId)) + { + if (!table.TryGetInstance(envelope.TargetInstanceId, out var instanceById)) + { + throw new RouteNotFoundException(envelope.TargetRole, $"The target instance '{envelope.TargetInstanceId}' of role '{envelope.TargetRole}' is not present in the dual-view route table (unknown id, offline, or removed)."); + } + + return instanceById; + } + + // D3 case 3:任意 Active 实例(Draining 不接新流量)。 + var activeInstances = table.GetActiveInstances(envelope.TargetRole); + if (activeInstances.Count == 0) + { + throw new RouteNotFoundException(envelope.TargetRole, $"Role '{envelope.TargetRole}' has no Active instance in the dual-view route table; the role is scaled to zero or fully draining."); + } + + // ponytail: 确定性首选策略——无粘性需求时最简正确;需要按 key 亲和时接入 ConsistentHashServerInstanceSelector(Unified/)。 + return activeInstances[0]; + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouteEnvelopeMessage.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouteEnvelopeMessage.cs new file mode 100644 index 000000000..1565edb7b --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/RoleRouteEnvelopeMessage.cs @@ -0,0 +1,120 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.ProtoBuf.Net; +using ProtoBuf; + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// 跨进程路由信封传输协议(C143d D3 case 2/3 wire 格式)。 +/// +/// +/// The wire representation of a cross-process routing envelope (C143d D3 case 2/3). +/// serializes this message through the standard +/// codec frame, so the bytes on the wire are indistinguishable from any other +/// RemoteMessaging packet. The receiving side (envelope unpacking back into local +/// delivery) is delivered with C143e; until then its message id is only a reserved +/// constant — it is written into the frame header but never registered in +/// MessageProtoHelper. +/// +[ProtoContract] +public sealed class RoleRouteEnvelopeMessage : MessageObject +{ + /// + /// 预留消息 Id(负数内部段 -130 的子号 10;仅写入帧头,接收端 change 注册后再启用)。 + /// + /// + /// The reserved message id (inner segment -130, sub id 10). Written into frame + /// headers only; it becomes meaningful once the receiving change registers it. + /// + public const int ReservedMessageId = unchecked((int)(((-130) << 16) + 10)); + + /// + /// 获取或设置目标 Role 名。 + /// + /// + /// Gets or sets the target role name. + /// + [ProtoMember(1)] + public string TargetRole { get; set; } + + /// + /// 获取或设置本地投递目标 ActorId(跨进程跳保持透传)。 + /// + /// + /// Gets or sets the local delivery target actor id (passed through the remote hop). + /// + [ProtoMember(2)] + public long TargetActorId { get; set; } + + /// + /// 获取或设置目标实例 Id(D3 case 2 语义;case 3 时为空)。 + /// + /// + /// Gets or sets the target instance id (D3 case 2; empty for case 3). + /// + [ProtoMember(3)] + public string TargetInstanceId { get; set; } + + /// + /// 获取或设置内嵌消息的消息 Id(接收端据此还原消息类型)。 + /// + /// + /// Gets or sets the embedded routed message's message id (the receiving side + /// restores the concrete type from it). + /// + [ProtoMember(4)] + public int InnerMessageId { get; set; } + + /// + /// 获取或设置内嵌消息的序列化字节。 + /// + /// + /// Gets or sets the embedded routed message's serialized bytes. The inner + /// message travels as id + bytes instead of a polymorphic MessageObject member + /// on purpose: the vendored protobuf runtime rejects unregistered sub-types on + /// a base-typed member (ThrowUnexpectedSubtype), and business message types are + /// only registered per-assembly by the receiving change — the id + bytes form + /// keeps this contract independent of any registration. + /// + [ProtoMember(5)] + public byte[] InnerMessageBytes { get; set; } + + /// + public override void Clear() + { + TargetRole = null; + TargetActorId = 0; + TargetInstanceId = null; + InnerMessageId = 0; + InnerMessageBytes = null; + } +} diff --git a/GameFrameX.NetWork.RemoteMessaging/Routing/TcpEnvelopeForwarder.cs b/GameFrameX.NetWork.RemoteMessaging/Routing/TcpEnvelopeForwarder.cs new file mode 100644 index 000000000..491a4e614 --- /dev/null +++ b/GameFrameX.NetWork.RemoteMessaging/Routing/TcpEnvelopeForwarder.cs @@ -0,0 +1,198 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using System.Collections.Concurrent; +using System.Net.Sockets; +using GameFrameX.NetWork.RemoteMessaging.Transport; +using GameFrameX.ProtoBuf.Net; + +namespace GameFrameX.NetWork.RemoteMessaging.Routing; + +/// +/// TCP 信封转发器(C143d D3 case 2/3 默认发送通道)。 +/// +/// +/// The default TCP send channel for D3 case 2/3 (C143d). +/// One connection bundle per target endpoint (a provider owning a single pooled +/// connection plus a whole-frame write lock), so different instances stay on +/// independent connections while concurrent forwards to the same endpoint are +/// serialized — a frame's Write+Flush always runs inside the endpoint's write +/// lock, so frame bytes never interleave. Frames reuse the standard codec +/// layout, making the bytes compatible with the existing protocol stack; a +/// write failure invalidates that endpoint's connection so the next forward +/// reconnects. +/// +public sealed class TcpEnvelopeForwarder : IEnvelopeForwarder, IDisposable +{ + /// + /// 消息编解码器(标准帧格式)。 + /// + /// + /// The message codec (the standard frame layout). + /// + private readonly IMessageCodec _messageCodec; + + /// + /// 目标端点 → 连接束(连接提供器 + 整帧写锁)。 + /// + /// + /// The per-endpoint bundles: a connection provider (owning a single pooled + /// connection) plus the write lock that serializes whole-frame writes on + /// that connection, so concurrent forwards to the same endpoint can never + /// interleave frame bytes. + /// + private readonly ConcurrentDictionary _endpointConnections = new ConcurrentDictionary(StringComparer.Ordinal); + + /// + /// 初始化 TCP 信封转发器(标准编解码器)。 + /// + /// + /// Initializes the forwarder with the default codec. + /// + public TcpEnvelopeForwarder() + { + _messageCodec = new DefaultMessageCodec(); + } + + /// + /// 初始化 TCP 信封转发器(指定编解码器;注入用)。 + /// + /// + /// Initializes the forwarder with a specific codec (injection point). + /// + /// 消息编解码器 / The message codec + public TcpEnvelopeForwarder(IMessageCodec messageCodec) + { + ArgumentNullException.ThrowIfNull(messageCodec, nameof(messageCodec)); + + _messageCodec = messageCodec; + } + + /// + /// 将路由信封编码为标准帧并写到目标端点。 + /// + /// + /// Encodes the envelope into a standard frame and writes it to the target + /// endpoint's pooled stream. Transport failures invalidate the connection + /// (so the next attempt reconnects) and propagate to the caller. + /// + /// 已解析的目标端点 / The parsed target endpoint + /// 路由信封 / The routing envelope + /// 取消令牌 / The cancellation token + /// 异步任务 / Async task + /// 为 null 时抛出 / Thrown when endpoint or envelope is null + public async Task ForwardAsync(Discovery.ParsedEndpoint endpoint, MessageEnvelope envelope, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endpoint, nameof(endpoint)); + ArgumentNullException.ThrowIfNull(envelope, nameof(envelope)); + + var endpointKey = $"{endpoint.Host}:{endpoint.Port}"; + var connection = _endpointConnections.GetOrAdd(endpointKey, delegate (string key) { return new EndpointConnection(); }); + var envelopeMessage = new RoleRouteEnvelopeMessage + { + TargetRole = envelope.TargetRole, + TargetActorId = envelope.TargetActorId, + TargetInstanceId = envelope.TargetInstanceId, + InnerMessageId = envelope.Message.MessageId, + InnerMessageBytes = ProtoBufSerializerHelper.Serialize(envelope.Message), + }; + envelopeMessage.SetMessageId(RoleRouteEnvelopeMessage.ReservedMessageId); + + using (var frame = _messageCodec.Encode(envelopeMessage)) + { + try + { + // 整帧串行化:提供器的信号量只保护连接获取/创建,Write+Flush 必须在同一把写锁的临界区内完成, + // 否则同端点的并发 ForwardAsync 会交错帧字节,接收端无法解析长度前缀。 + await connection.WriteLock.WaitAsync(cancellationToken); + try + { + var stream = await connection.Provider.GetOrCreateStreamAsync(endpoint.Host, endpoint.Port, cancellationToken); + await stream.WriteAsync(frame.Memory, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + finally + { + connection.WriteLock.Release(); + } + } + catch (Exception exception) when (exception is IOException || exception is SocketException || exception is OperationCanceledException) + { + connection.Provider.Invalidate(); + throw; + } + } + } + + /// + /// 释放全部端点连接束。 + /// + /// + /// Disposes every per-endpoint bundle (connection provider and write lock). + /// + public void Dispose() + { + foreach (var pair in _endpointConnections) + { + pair.Value.Dispose(); + } + + _endpointConnections.Clear(); + } + + /// + /// 单端点连接束:连接提供器 + 整帧写锁。 + /// + /// + /// The per-endpoint bundle: the connection provider (a single pooled + /// connection) plus the SemaphoreSlim write lock serializing whole-frame + /// writes on that connection. + /// + private sealed class EndpointConnection : IDisposable + { + /// 连接提供器(单连接池)/ The connection provider (one pooled connection) + public IConnectionProvider Provider { get; } = new TcpConnectionProvider(); + + /// 整帧写锁 / The whole-frame write lock + public SemaphoreSlim WriteLock { get; } = new SemaphoreSlim(1, 1); + + /// + /// 释放连接与写锁。 + /// + /// + /// Releases the connection and the write lock. + /// + public void Dispose() + { + Provider.Dispose(); + WriteLock.Dispose(); + } + } +} diff --git a/Tests/GameFrameX.Tests/Discovery/EndpointParserTests.cs b/Tests/GameFrameX.Tests/Discovery/EndpointParserTests.cs new file mode 100644 index 000000000..46a699bcc --- /dev/null +++ b/Tests/GameFrameX.Tests/Discovery/EndpointParserTests.cs @@ -0,0 +1,134 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Discovery; + +namespace GameFrameX.Tests.Discovery; + +/// +/// EndpointParser 地址格式用例集(C143d D15 / AC-4a:域名/容器名/Kubernetes Service 名/IPv4/IPv6 方括号)。 +/// +/// +/// The address-format suite for EndpointParser (C143d D15 / AC-4a). These are the +/// CI cases required by the change: every supported shape must parse to the exact +/// scheme/host/port/address-kind triple, and every structural violation must fail +/// loudly with EndpointFormatException. +/// +public sealed class EndpointParserTests +{ + [Theory] + [InlineData("tcp://game-1.gameframex:7777", "tcp", "game-1.gameframex", 7777, EndpointAddressKind.DnsName)] + [InlineData("TCP://Game-1.GameFrameX:7777", "tcp", "Game-1.GameFrameX", 7777, EndpointAddressKind.DnsName)] + [InlineData("kcp://match-service:9000", "kcp", "match-service", 9000, EndpointAddressKind.DnsName)] + [InlineData("ws://social.internal.svc.cluster.local:8080", "ws", "social.internal.svc.cluster.local", 8080, EndpointAddressKind.DnsName)] + [InlineData("wss://gateway.example.com:443", "wss", "gateway.example.com", 443, EndpointAddressKind.DnsName)] + [InlineData("tcp://10.0.0.17:7777", "tcp", "10.0.0.17", 7777, EndpointAddressKind.IPv4)] + [InlineData("tcp://127.0.0.1:1", "tcp", "127.0.0.1", 1, EndpointAddressKind.IPv4)] + [InlineData("tcp://[::1]:7777", "tcp", "::1", 7777, EndpointAddressKind.IPv6)] + [InlineData("kcp://[2001:db8::1]:9000", "kcp", "2001:db8::1", 9000, EndpointAddressKind.IPv6)] + [InlineData("tcp://host:65535", "tcp", "host", 65535, EndpointAddressKind.DnsName)] + public void Parse_WithSupportedShapes_ShouldProduceExactTriple(string endpoint, string expectedScheme, string expectedHost, int expectedPort, EndpointAddressKind expectedAddressKind) + { + var parsed = EndpointParser.Parse(endpoint); + + Assert.Equal(expectedScheme, parsed.Scheme); + Assert.Equal(expectedHost, parsed.Host); + Assert.Equal(expectedPort, parsed.Port); + Assert.Equal(expectedAddressKind, parsed.AddressKind); + } + + [Fact] + public void Parse_WithWhitespaceAround_ShouldTrim() + { + var parsed = EndpointParser.Parse(" tcp://host:7777 "); + + Assert.Equal("tcp", parsed.Scheme); + Assert.Equal("host", parsed.Host); + Assert.Equal(7777, parsed.Port); + } + + [Fact] + public void Parse_ToString_ShouldRoundTrip() + { + var original = EndpointParser.Parse("tcp://[2001:db8::1]:9000"); + + var roundTripped = EndpointParser.Parse(original.ToString()); + + Assert.Equal(original.Scheme, roundTripped.Scheme); + Assert.Equal(original.Host, roundTripped.Host); + Assert.Equal(original.Port, roundTripped.Port); + Assert.Equal(original.AddressKind, roundTripped.AddressKind); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Parse_WithNullOrEmpty_ShouldThrow(string endpoint) + { + if (endpoint == null) + { + Assert.Throws(() => EndpointParser.Parse(endpoint)); + } + else + { + Assert.Throws(() => EndpointParser.Parse(endpoint)); + } + } + + [Theory] + [InlineData("game-1.gameframex:7777")] // scheme 缺失 + [InlineData("://host:7777")] // scheme 为空 + [InlineData("http://host:7777")] // scheme 不受支持 + [InlineData("tcp://host")] // 端口缺失 + [InlineData("tcp://host:")] // 端口为空 + [InlineData("tcp://host:abc")] // 端口非数字 + [InlineData("tcp://host:0")] // 端口越界(下界) + [InlineData("tcp://host:65536")] // 端口越界(上界) + [InlineData("tcp://host:-1")] // 端口为负 + [InlineData("tcp://")] // host 缺失 + [InlineData("tcp://:7777")] // host 为空 + [InlineData("tcp://[::1")] // IPv6 方括号未闭合 + [InlineData("tcp://[]:7777")] // IPv6 字面量为空 + [InlineData("tcp://[not-an-address]:7777")] // 方括号内非 IPv6 + [InlineData("tcp://[::1]7777")] // IPv6 后缺端口冒号 + [InlineData("tcp://[::1]:")] // IPv6 端口缺失 + [InlineData("tcp://fe80::1:7777")] // 未加方括号的 IPv6 + [InlineData("tcp://host/path:7777")] // host 含路径分隔符 + [InlineData("tcp://user@host:7777")] // host 含 userinfo 分隔符 + [InlineData("tcp://host?q=1:7777")] // host 含查询分隔符 + [InlineData("tcp://host#frag:7777")] // host 含片段分隔符 + [InlineData("tcp://ho st:7777")] // host 含空白 + [InlineData("tcp://a:b:7777")] // host 含冒号 + public void Parse_WithStructuralViolations_ShouldThrowEndpointFormatException(string endpoint) + { + Assert.Throws(() => EndpointParser.Parse(endpoint)); + } +} diff --git a/Tests/GameFrameX.Tests/Discovery/MongoDiscoveryRemoteRoleRouterTests.cs b/Tests/GameFrameX.Tests/Discovery/MongoDiscoveryRemoteRoleRouterTests.cs new file mode 100644 index 000000000..a1552ecd9 --- /dev/null +++ b/Tests/GameFrameX.Tests/Discovery/MongoDiscoveryRemoteRoleRouterTests.cs @@ -0,0 +1,324 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using System.Net; +using System.Net.Sockets; +using System.Buffers.Binary; +using GameFrameX.NetWork.Messages; +using GameFrameX.NetWork.RemoteMessaging.Discovery; +using GameFrameX.NetWork.RemoteMessaging.Routing; +using GameFrameX.ProtoBuf.Net; +using ProtoBuf; + +namespace GameFrameX.Tests.Discovery; + +/// +/// MongoDiscoveryRemoteRoleRouter 的选实例与转发语义测试(C143d D3 case 2/3)。 +/// +/// +/// Selection and forwarding semantics of MongoDiscoveryRemoteRoleRouter (C143d D3 +/// case 2/3) without any Mongo dependency: the dual-view table comes from a fixed +/// provider and the send channel from a recording fake. Also covers the real TCP +/// forwarder's wire format against a local TcpListener. +/// +public sealed class MongoDiscoveryRemoteRoleRouterTests +{ + /// + /// 固定路由表提供者(测试替身)。 + /// + private sealed class FixedTableProvider : IRoleRouteTableProvider + { + public FixedTableProvider(RoleRouteTable table) + { + Current = table; + } + + public RoleRouteTable Current { get; } + } + + /// + /// 记录型转发器(测试替身)。 + /// + private sealed class RecordingForwarder : IEnvelopeForwarder + { + public List> Forwards { get; } = new List>(); + + public Task ForwardAsync(ParsedEndpoint endpoint, MessageEnvelope envelope, CancellationToken cancellationToken = default) + { + Forwards.Add(new KeyValuePair(endpoint, envelope)); + return Task.CompletedTask; + } + } + + /// + /// wire 格式用可序列化测试载荷(内嵌消息走真实 protobuf 契约;Equivalence 套件的载荷无 ProtoContract,不可复用)。 + /// + [ProtoContract] + private sealed class WirePayloadMessage : MessageObject + { + [ProtoMember(1)] + public long PlayerId { get; set; } + + /// + public override void Clear() + { + PlayerId = 0; + } + } + + /// + /// 测试载荷的固定消息 Id(模拟 MessageProtoHelper 注册时统一分配的 Id)。 + /// + private const int WirePayloadMessageId = 0x4321; + + /// + /// 构造一个双实例表:Game 角色 Active + Draining,Social 角色 Active 单实例。 + /// + private static RoleRouteTable BuildTable() + { + var now = DateTime.UtcNow; + return RoleRouteTable.FromInstances(new List + { + new InstanceDescriptor("Game", "game-active-1", "tcp://10.0.0.1:7001", InstanceStatus.Active, 10, EndpointAddressKind.IPv4, 101, now), + new InstanceDescriptor("Game", "game-draining-1", "tcp://10.0.0.2:7002", InstanceStatus.Draining, 20, EndpointAddressKind.IPv4, 102, now), + new InstanceDescriptor("Social", "social-active-1", "tcp://social.internal:7101", InstanceStatus.Active, 30, EndpointAddressKind.DnsName, 103, now), + }); + } + + private static MessageEnvelope BuildEnvelope(string targetRole, string targetInstanceId = null) + { + var payload = new WirePayloadMessage { PlayerId = 42 }; + payload.SetMessageId(WirePayloadMessageId); + return new MessageEnvelope(targetRole, payload, 99, targetInstanceId); + } + + [Fact] + public async Task ForwardAsync_Case3_ShouldPickFirstActiveInstanceAndSkipDraining() + { + var forwarder = new RecordingForwarder(); + var router = new MongoDiscoveryRemoteRoleRouter(new FixedTableProvider(BuildTable()), forwarder); + + var delivery = await router.ForwardAsync(BuildEnvelope("Game")); + + Assert.Equal(RoleRouteDelivery.RemoteForwarded, delivery); + var forward = Assert.Single(forwarder.Forwards); + Assert.Equal("game-active-1", forward.Value.TargetInstanceId); + Assert.Equal("10.0.0.1", forward.Key.Host); + Assert.Equal(7001, forward.Key.Port); + Assert.Equal(EndpointAddressKind.IPv4, forward.Key.AddressKind); + } + + [Fact] + public async Task ForwardAsync_Case2_ShouldResolveKnownInstanceIdIncludingDraining() + { + var forwarder = new RecordingForwarder(); + var router = new MongoDiscoveryRemoteRoleRouter(new FixedTableProvider(BuildTable()), forwarder); + + await router.ForwardAsync(BuildEnvelope("Game", "game-draining-1")); + + var forward = Assert.Single(forwarder.Forwards); + Assert.Equal("game-draining-1", forward.Value.TargetInstanceId); + Assert.Equal("10.0.0.2", forward.Key.Host); + Assert.Equal(7002, forward.Key.Port); + } + + [Fact] + public async Task ForwardAsync_Case2_WithUnknownInstanceId_ShouldThrowRouteNotFound() + { + var forwarder = new RecordingForwarder(); + var router = new MongoDiscoveryRemoteRoleRouter(new FixedTableProvider(BuildTable()), forwarder); + + await Assert.ThrowsAsync(() => router.ForwardAsync(BuildEnvelope("Game", "game-unknown-9"))); + } + + [Fact] + public async Task ForwardAsync_Case3_WithNoActiveInstance_ShouldThrowRouteNotFound() + { + var forwarder = new RecordingForwarder(); + var router = new MongoDiscoveryRemoteRoleRouter(new FixedTableProvider(BuildTable()), forwarder); + + await Assert.ThrowsAsync(() => router.ForwardAsync(BuildEnvelope("Match"))); + } + + [Fact] + public async Task ForwardAsync_WithMalformedAdvertiseEndpoint_ShouldThrowEndpointFormat() + { + var table = RoleRouteTable.FromInstances(new List + { + new InstanceDescriptor("Broken", "broken-1", "tcp://missing-port", InstanceStatus.Active, 0, EndpointAddressKind.DnsName, 104, DateTime.UtcNow), + }); + var forwarder = new RecordingForwarder(); + var router = new MongoDiscoveryRemoteRoleRouter(new FixedTableProvider(table), forwarder); + + await Assert.ThrowsAsync(() => router.ForwardAsync(BuildEnvelope("Broken"))); + } + + [Fact] + public async Task ForwardAsync_WithRealTcpForwarder_ShouldWriteStandardFrameWithEnvelopePayload() + { + using (var listener = new TcpListener(IPAddress.Loopback, 0)) + { + listener.Start(); + var receiveTask = ReceiveOneFrameAsync(listener); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var table = RoleRouteTable.FromInstances(new List + { + new InstanceDescriptor("Game", "game-tcp-1", $"tcp://127.0.0.1:{port}", InstanceStatus.Active, 0, EndpointAddressKind.IPv4, 105, DateTime.UtcNow), + }); + + using (var forwarder = new TcpEnvelopeForwarder()) + { + var router = new MongoDiscoveryRemoteRoleRouter(new FixedTableProvider(table), forwarder); + var delivery = await router.ForwardAsync(BuildEnvelope("Game", "game-tcp-1")); + Assert.Equal(RoleRouteDelivery.RemoteForwarded, delivery); + } + + var envelope = await receiveTask; + Assert.Equal("Game", envelope.TargetRole); + Assert.Equal(99, envelope.TargetActorId); + Assert.Equal("game-tcp-1", envelope.TargetInstanceId); + Assert.Equal(WirePayloadMessageId, envelope.InnerMessageId); + var innerMessage = Assert.IsType(ProtoBufSerializerHelper.Deserialize(envelope.InnerMessageBytes, typeof(WirePayloadMessage))); + Assert.Equal(42, innerMessage.PlayerId); + } + } + + [Fact] + public async Task ForwardAsync_WithConcurrentCallsOnSameEndpoint_ShouldNeverInterleaveFrames() + { + using (var listener = new TcpListener(IPAddress.Loopback, 0)) + { + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + const int frameCount = 32; + var receiveTask = ReceiveFramesAsync(listener, frameCount); + var table = RoleRouteTable.FromInstances(new List + { + new InstanceDescriptor("Game", "game-tcp-1", $"tcp://127.0.0.1:{port}", InstanceStatus.Active, 0, EndpointAddressKind.IPv4, 106, DateTime.UtcNow), + }); + + using (var forwarder = new TcpEnvelopeForwarder()) + { + var router = new MongoDiscoveryRemoteRoleRouter(new FixedTableProvider(table), forwarder); + var sends = new List>(); + for (var index = 0; index < frameCount; index++) + { + sends.Add(router.ForwardAsync(BuildEnvelope("Game", "game-tcp-1"))); + } + + await Task.WhenAll(sends); + } + + // 同端点并发整帧写入必须串行化:每一帧的长度前缀与载荷都完整可解析,不允许交错破坏。 + var completed = await Task.WhenAny(receiveTask, Task.Delay(TimeSpan.FromSeconds(15))); + Assert.Same(receiveTask, completed); + var envelopes = await receiveTask; + Assert.Equal(frameCount, envelopes.Count); + foreach (var envelope in envelopes) + { + Assert.Equal("Game", envelope.TargetRole); + Assert.Equal("game-tcp-1", envelope.TargetInstanceId); + var innerMessage = Assert.IsType(ProtoBufSerializerHelper.Deserialize(envelope.InnerMessageBytes, typeof(WirePayloadMessage))); + Assert.Equal(42, innerMessage.PlayerId); + } + } + } + + /// + /// 接收并解码指定数量的标准帧(每帧 4B 总长 + 10B 头 + protobuf 载荷,帧间不允许交错)。 + /// + private static async Task> ReceiveFramesAsync(TcpListener listener, int frameCount) + { + var envelopes = new List(frameCount); + using (var client = await listener.AcceptTcpClientAsync()) + using (var stream = client.GetStream()) + { + for (var index = 0; index < frameCount; index++) + { + var header = new byte[14]; + await ReadExactAsync(stream, header); + var totalLength = BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(0, 4)); + var messageId = BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(10, 4)); + Assert.Equal(RoleRouteEnvelopeMessage.ReservedMessageId, messageId); + + var payloadLength = totalLength - header.Length; + Assert.True(payloadLength > 0, $"The frame length prefix is corrupted: total length {totalLength}."); + var payload = new byte[payloadLength]; + await ReadExactAsync(stream, payload); + envelopes.Add((RoleRouteEnvelopeMessage)ProtoBufSerializerHelper.Deserialize(payload, typeof(RoleRouteEnvelopeMessage))); + } + + listener.Stop(); + } + + return envelopes; + } + + /// + /// 接收并解码一帧标准格式信封消息(4B 总长 + 10B 头 + protobuf 载荷)。 + /// + private static async Task ReceiveOneFrameAsync(TcpListener listener) + { + using (var client = await listener.AcceptTcpClientAsync()) + using (var stream = client.GetStream()) + { + var header = new byte[14]; + await ReadExactAsync(stream, header); + var totalLength = BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(0, 4)); + var messageId = BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(10, 4)); + Assert.Equal(RoleRouteEnvelopeMessage.ReservedMessageId, messageId); + + var payloadLength = totalLength - header.Length; + var payload = new byte[payloadLength]; + await ReadExactAsync(stream, payload); + listener.Stop(); + return (RoleRouteEnvelopeMessage)ProtoBufSerializerHelper.Deserialize(payload, typeof(RoleRouteEnvelopeMessage)); + } + } + + /// + /// 精确读取指定字节数。 + /// + private static async Task ReadExactAsync(NetworkStream stream, byte[] buffer) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = await stream.ReadAsync(buffer, offset, buffer.Length - offset); + if (read == 0) + { + throw new IOException("The sender closed the connection before the frame was complete."); + } + + offset += read; + } + } + +} diff --git a/Tests/GameFrameX.Tests/Discovery/MongoEndpointIntegrationTests.cs b/Tests/GameFrameX.Tests/Discovery/MongoEndpointIntegrationTests.cs new file mode 100644 index 000000000..7e35e0924 --- /dev/null +++ b/Tests/GameFrameX.Tests/Discovery/MongoEndpointIntegrationTests.cs @@ -0,0 +1,446 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Discovery; +using MongoDB.Driver; + +namespace GameFrameX.Tests.Discovery; + +/// +/// MongoEndpointRegistry / MongoEndpointWatcher 的 Mongo 集成测试(C143d D11/D15)。 +/// +/// +/// Mongo-backed integration tests for the heartbeat writer and reader (C143d D11/D15), +/// following the repository's existing GAMEFRAMEX_TEST_MONGODB_CONNECTION_STRING gating +/// convention (MongoDbServiceConnectionTests): without the variable the tests skip so +/// plain dotnet test stays green on Mongo-less machines; the topology-equivalence +/// CI workflow sets the variable against a Mongo service container so the suite really +/// runs there. Intervals are shrunk (150 ms heartbeat / 150 ms poll / 450 ms staleness) +/// to keep the wall time small while exercising the same state machine. +/// +public sealed class MongoEndpointIntegrationTests : IDisposable +{ + /// + /// Mongo 连接串(未设置时跳过)。 + /// + private readonly string _connectionString = Environment.GetEnvironmentVariable("GAMEFRAMEX_TEST_MONGODB_CONNECTION_STRING") ?? string.Empty; + + /// + /// 是否跳过全部用例。 + /// + private bool ShouldSkip + { + get + { + return string.IsNullOrWhiteSpace(_connectionString); + } + } + + /// + /// 本测试类的独立控制库。 + /// + private IMongoDatabase _controlDatabase; + + /// + /// 创建独立控制库(每个用例独立 database 防串扰)。 + /// + private IMongoDatabase CreateControlDatabase() + { + if (_controlDatabase == null) + { + var client = new MongoClient(_connectionString); + _controlDatabase = client.GetDatabase($"gameframex_control_test_{Guid.NewGuid():N}"); + } + + return _controlDatabase; + } + + [Fact] + public async Task Registry_ShouldPublishBootingThenActiveAndKeepHeartbeatFresh() + { + if (ShouldSkip) + { + return; + } + + var controlDatabase = CreateControlDatabase(); + var selfDescriptor = new InstanceDescriptor("Game", "integration-registry-1", "tcp://127.0.0.1:7701", InstanceStatus.Booting, 0, EndpointAddressKind.IPv4, 9001, DateTime.UtcNow); + using (var registry = new MongoEndpointRegistry(controlDatabase, selfDescriptor, TimeSpan.FromMilliseconds(150))) + { + await registry.StartAsync(); + + var collection = controlDatabase.GetCollection(MongoEndpointRegistry.HeartbeatCollectionName); + var bootingDocument = await WaitForDocumentAsync(collection, "integration-registry-1"); + Assert.NotNull(bootingDocument); + // 启动即宣告 Booting 而非 Active:其他进程在服务真正就绪前不应向本实例路由流量。 + Assert.Equal("Booting", bootingDocument["status"].AsString); + Assert.Equal("Game", bootingDocument["role"].AsString); + Assert.Equal("tcp://127.0.0.1:7701", bootingDocument["advertiseEndpoint"].AsString); + Assert.Equal(9001, bootingDocument["incarnation"].AsInt64); + + await registry.MarkActiveAsync(); + var activeDocument = await WaitForStatusAsync(collection, "integration-registry-1", "Active"); + Assert.NotNull(activeDocument); + + // 首次写入后心跳循环必须继续全量 upsert:lastHeartbeat 持续前进(否则 watcher 会在三周期后将健康实例误判下线)。 + var heartbeatBefore = activeDocument["lastHeartbeat"].ToUniversalTime(); + var heartbeatAdvanced = false; + var deadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < deadline) + { + var latest = await collection.Find(candidate => candidate["_id"] == "integration-registry-1").FirstOrDefaultAsync(); + if (latest != null && latest["lastHeartbeat"].ToUniversalTime() > heartbeatBefore) + { + heartbeatAdvanced = true; + break; + } + + await Task.Delay(100); + } + + Assert.True(heartbeatAdvanced, "The heartbeat lastHeartbeat did not advance after the initial write."); + + await registry.StopAsync(); + var stoppedDocument = await collection.Find(candidate => candidate["_id"] == "integration-registry-1").FirstOrDefaultAsync(); + Assert.NotNull(stoppedDocument); + Assert.Equal("Stopped", stoppedDocument["status"].AsString); + } + } + + [Fact] + public async Task Watcher_ShouldSkipEventsForIneligibleFirstObservations() + { + if (ShouldSkip) + { + return; + } + + var controlDatabase = CreateControlDatabase(); + var collection = controlDatabase.GetCollection(MongoEndpointRegistry.HeartbeatCollectionName); + var events = new RecordingInstanceEvents(); + using (var watcher = new MongoEndpointWatcher(controlDatabase, TimeSpan.FromMilliseconds(150), TimeSpan.FromSeconds(30))) + { + watcher.Subscribe(events); + await watcher.StartAsync(); + + // 三类不具备路由资格的首次观测:Stopped、陈旧(超过判活阈值)、Booting;外加一个首次即为 Draining 的实例。 + var stoppedId = $"integration-ineligible-stopped-{Guid.NewGuid():N}"; + var staleId = $"integration-ineligible-stale-{Guid.NewGuid():N}"; + var bootingId = $"integration-ineligible-booting-{Guid.NewGuid():N}"; + var drainingId = $"integration-ineligible-draining-{Guid.NewGuid():N}"; + await collection.InsertManyAsync(new[] + { + new MongoDB.Bson.BsonDocument + { + { "_id", stoppedId }, { "role", "Game" }, { "advertiseEndpoint", "tcp://10.0.0.1:7501" }, { "status", "Stopped" }, + { "load", 0 }, { "addressKind", "IPv4" }, { "incarnation", 9400L }, { "lastHeartbeat", DateTime.UtcNow }, + }, + new MongoDB.Bson.BsonDocument + { + { "_id", staleId }, { "role", "Game" }, { "advertiseEndpoint", "tcp://10.0.0.2:7502" }, { "status", "Active" }, + { "load", 0 }, { "addressKind", "IPv4" }, { "incarnation", 9401L }, { "lastHeartbeat", DateTime.UtcNow.AddSeconds(-60) }, + }, + new MongoDB.Bson.BsonDocument + { + { "_id", bootingId }, { "role", "Game" }, { "advertiseEndpoint", "tcp://10.0.0.3:7503" }, { "status", "Booting" }, + { "load", 0 }, { "addressKind", "IPv4" }, { "incarnation", 9402L }, { "lastHeartbeat", DateTime.UtcNow }, + }, + new MongoDB.Bson.BsonDocument + { + { "_id", drainingId }, { "role", "Match" }, { "advertiseEndpoint", "tcp://match.internal:7504" }, { "status", "Draining" }, + { "load", 0 }, { "addressKind", "DnsName" }, { "incarnation", 9403L }, { "lastHeartbeat", DateTime.UtcNow }, + }, + }); + + // 等若干轮 poll:不具备路由资格的实例既不发 Online,也不进路由表;Draining 首次观测发 Draining 且进 Instance 视图。 + await Task.Delay(TimeSpan.FromMilliseconds(1000)); + lock (events.Observed) + { + Assert.DoesNotContain((RoleInstanceChangeKind.Online, stoppedId), events.Observed); + Assert.DoesNotContain((RoleInstanceChangeKind.Online, staleId), events.Observed); + Assert.DoesNotContain((RoleInstanceChangeKind.Online, bootingId), events.Observed); + Assert.Contains((RoleInstanceChangeKind.Draining, drainingId), events.Observed); + } + + Assert.False(watcher.Current.TryGetInstance(stoppedId, out _)); + Assert.False(watcher.Current.TryGetInstance(staleId, out _)); + Assert.False(watcher.Current.TryGetInstance(bootingId, out _)); + Assert.True(watcher.Current.TryGetInstance(drainingId, out _)); + + // Booting → Active 跃迁后才发 Online 并进入路由表(与写侧 MarkActive 的就绪语义闭环)。 + var update = Builders.Update + .Set("status", "Active") + .Set("lastHeartbeat", DateTime.UtcNow); + await collection.UpdateOneAsync(candidate => candidate["_id"] == bootingId, update); + await WaitUntilAsync(delegate () + { + return watcher.Current.TryGetInstance(bootingId, out _) && watcher.Current.GetActiveInstances("Game").Any(instance => instance.InstanceId == bootingId); + }, TimeSpan.FromSeconds(10)); + Assert.Contains((RoleInstanceChangeKind.Online, bootingId), events.Observed); + } + } + + [Fact] + public async Task Watcher_ShouldDiscoverOnlineAndBroadcastEvictedOnRemoval() + { + if (ShouldSkip) + { + return; + } + + var controlDatabase = CreateControlDatabase(); + var collection = controlDatabase.GetCollection(MongoEndpointRegistry.HeartbeatCollectionName); + var events = new RecordingInstanceEvents(); + using (var watcher = new MongoEndpointWatcher(controlDatabase, TimeSpan.FromMilliseconds(150), TimeSpan.FromMilliseconds(450))) + { + watcher.Subscribe(events); + await watcher.StartAsync(); + + // 直接写一份心跳文档(扮演另一进程的写侧),watcher 应广播 Online 且路由表包含该实例。 + var instanceId = $"integration-watcher-{Guid.NewGuid():N}"; + await collection.InsertOneAsync(new MongoDB.Bson.BsonDocument + { + { "_id", instanceId }, + { "role", "Social" }, + { "advertiseEndpoint", "tcp://social.internal:7101" }, + { "status", "Active" }, + { "load", 0 }, + { "addressKind", "DnsName" }, + { "incarnation", 9100L }, + { "lastHeartbeat", DateTime.UtcNow }, + }); + + await WaitUntilAsync(() => watcher.Current.TryGetInstance(instanceId, out _), TimeSpan.FromSeconds(10)); + Assert.Contains((RoleInstanceChangeKind.Online, instanceId), events.Observed); + + // 删除文档(模拟 TTL 清除),watcher 应广播 Evicted 且路由表摘除该实例。 + await collection.DeleteOneAsync(candidate => candidate["_id"] == instanceId); + await WaitUntilAsync(() => !watcher.Current.TryGetInstance(instanceId, out _), TimeSpan.FromSeconds(10)); + Assert.Contains((RoleInstanceChangeKind.Evicted, instanceId), events.Observed); + } + } + + [Fact] + public async Task Watcher_ShouldEmitOfflineThenOnlineOnIncarnationChange() + { + if (ShouldSkip) + { + return; + } + + var controlDatabase = CreateControlDatabase(); + var collection = controlDatabase.GetCollection(MongoEndpointRegistry.HeartbeatCollectionName); + var events = new RecordingInstanceEvents(); + using (var watcher = new MongoEndpointWatcher(controlDatabase, TimeSpan.FromMilliseconds(150), TimeSpan.FromMilliseconds(450))) + { + watcher.Subscribe(events); + await watcher.StartAsync(); + + var instanceId = $"integration-incarnation-{Guid.NewGuid():N}"; + await collection.InsertOneAsync(new MongoDB.Bson.BsonDocument + { + { "_id", instanceId }, + { "role", "Game" }, + { "advertiseEndpoint", "tcp://10.0.0.9:7301" }, + { "status", "Active" }, + { "load", 0 }, + { "addressKind", "IPv4" }, + { "incarnation", 9200L }, + { "lastHeartbeat", DateTime.UtcNow }, + }); + await WaitUntilAsync(() => watcher.Current.TryGetInstance(instanceId, out _), TimeSpan.FromSeconds(10)); + + // 同 instanceId 换 incarnation(进程重启语义):应广播 Offline+Online,而非 Recovered。 + var restarted = new MongoDB.Bson.BsonDocument + { + { "_id", instanceId }, + { "role", "Game" }, + { "advertiseEndpoint", "tcp://10.0.0.9:7302" }, + { "status", "Active" }, + { "load", 0 }, + { "addressKind", "IPv4" }, + { "incarnation", 9201L }, + { "lastHeartbeat", DateTime.UtcNow }, + }; + await collection.ReplaceOneAsync(candidate => candidate["_id"] == instanceId, restarted); + + await WaitUntilAsync(delegate () + { + return watcher.Current.TryGetInstance(instanceId, out var instance) && instance.Incarnation == 9201; + }, TimeSpan.FromSeconds(10)); + lock (events.Observed) + { + var kinds = events.Observed.Where(pair => pair.InstanceId == instanceId).Select(pair => pair.Kind).ToList(); + Assert.Equal(RoleInstanceChangeKind.Online, kinds[0]); + Assert.Contains(RoleInstanceChangeKind.Offline, kinds.Skip(1)); + Assert.Contains(RoleInstanceChangeKind.Online, kinds.Skip(1)); + Assert.DoesNotContain(RoleInstanceChangeKind.Recovered, kinds); + } + } + } + + [Fact] + public async Task Watcher_ShouldEmitDrainingAndExcludeFromRoleView() + { + if (ShouldSkip) + { + return; + } + + var controlDatabase = CreateControlDatabase(); + var collection = controlDatabase.GetCollection(MongoEndpointRegistry.HeartbeatCollectionName); + var events = new RecordingInstanceEvents(); + using (var watcher = new MongoEndpointWatcher(controlDatabase, TimeSpan.FromMilliseconds(150), TimeSpan.FromMilliseconds(450))) + { + watcher.Subscribe(events); + await watcher.StartAsync(); + + var instanceId = $"integration-draining-{Guid.NewGuid():N}"; + await collection.InsertOneAsync(new MongoDB.Bson.BsonDocument + { + { "_id", instanceId }, + { "role", "Match" }, + { "advertiseEndpoint", "tcp://match.internal:7401" }, + { "status", "Active" }, + { "load", 0 }, + { "addressKind", "DnsName" }, + { "incarnation", 9300L }, + { "lastHeartbeat", DateTime.UtcNow }, + }); + await WaitUntilAsync(() => watcher.Current.GetActiveInstances("Match").Count > 0, TimeSpan.FromSeconds(10)); + + // 状态跃迁 Active → Draining:应广播 Draining 事件;Role 视图摘除、Instance 视图保留(在途投递合法)。 + var update = Builders.Update + .Set("status", "Draining") + .Set("lastHeartbeat", DateTime.UtcNow); + await collection.UpdateOneAsync(candidate => candidate["_id"] == instanceId, update); + + await WaitUntilAsync(delegate () + { + return watcher.Current.GetActiveInstances("Match").Count == 0; + }, TimeSpan.FromSeconds(10)); + Assert.True(watcher.Current.TryGetInstance(instanceId, out _)); + Assert.Contains((RoleInstanceChangeKind.Draining, instanceId), events.Observed); + } + } + + /// + /// 轮询等待指定实例文档出现。 + /// + private static async Task WaitForDocumentAsync(IMongoCollection collection, string instanceId) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + MongoDB.Bson.BsonDocument document = null; + while (document == null && DateTime.UtcNow < deadline) + { + document = await collection.Find(candidate => candidate["_id"] == instanceId).FirstOrDefaultAsync(); + if (document == null) + { + await Task.Delay(100); + } + } + + return document; + } + + /// + /// 轮询等待指定实例文档达到期望状态。 + /// + private static async Task WaitForStatusAsync(IMongoCollection collection, string instanceId, string status) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + MongoDB.Bson.BsonDocument document = null; + while (DateTime.UtcNow < deadline) + { + document = await collection.Find(candidate => candidate["_id"] == instanceId).FirstOrDefaultAsync(); + if (document != null && document["status"].AsString == status) + { + return document; + } + + await Task.Delay(100); + } + + return document; + } + + /// + /// 轮询断言直到条件成立或超时。 + /// + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return; + } + + await Task.Delay(100); + } + + Assert.True(condition(), "The expected condition was not met within the timeout."); + } + + /// + /// 记录型事件订阅者。 + /// + private sealed class RecordingInstanceEvents : IRoleInstanceEvents + { + public List<(RoleInstanceChangeKind Kind, string InstanceId)> Observed { get; } = new List<(RoleInstanceChangeKind, string)>(); + + public void OnInstanceChanged(RoleInstanceChangeKind kind, InstanceDescriptor instance) + { + lock (Observed) + { + Observed.Add((kind, instance.InstanceId)); + } + } + } + + /// + /// 释放独立控制库。 + /// + public void Dispose() + { + if (_controlDatabase != null) + { + try + { + _controlDatabase.Client.DropDatabaseAsync(_controlDatabase.DatabaseNamespace.DatabaseName).GetAwaiter().GetResult(); + } + catch (Exception) + { + // 清理失败不影响测试结果。 + } + } + } +} diff --git a/Tests/GameFrameX.Tests/Discovery/RoleRouteTableTests.cs b/Tests/GameFrameX.Tests/Discovery/RoleRouteTableTests.cs new file mode 100644 index 000000000..70fec2460 --- /dev/null +++ b/Tests/GameFrameX.Tests/Discovery/RoleRouteTableTests.cs @@ -0,0 +1,70 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legal rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.NetWork.RemoteMessaging.Discovery; + +namespace GameFrameX.Tests.Discovery; + +/// +/// RoleRouteTable 双视图准入用例集(C143d D15 / D3 case 2/3)。 +/// +/// +/// The dual-view admission suite for RoleRouteTable (C143d D15 / D3 case 2/3): +/// only Active and Draining instances may enter the Instance view (and only +/// Active the Role view), so a registered-but-not-ready (Booting) or +/// decommissioned (Stopped/Removed) instance is never routable. +/// +public sealed class RoleRouteTableTests +{ + [Fact] + public void FromInstances_ShouldExcludeNonRoutableStatusesFromBothViews() + { + var now = DateTime.UtcNow; + var table = RoleRouteTable.FromInstances(new List + { + new InstanceDescriptor("Game", "game-active-1", "tcp://10.0.0.1:7001", InstanceStatus.Active, 10, EndpointAddressKind.IPv4, 201, now), + new InstanceDescriptor("Game", "game-draining-1", "tcp://10.0.0.2:7002", InstanceStatus.Draining, 20, EndpointAddressKind.IPv4, 202, now), + new InstanceDescriptor("Game", "game-booting-1", "tcp://10.0.0.3:7003", InstanceStatus.Booting, 0, EndpointAddressKind.IPv4, 203, now), + new InstanceDescriptor("Game", "game-stopped-1", "tcp://10.0.0.4:7004", InstanceStatus.Stopped, 0, EndpointAddressKind.IPv4, 204, now), + new InstanceDescriptor("Game", "game-removed-1", "tcp://10.0.0.5:7005", InstanceStatus.Removed, 0, EndpointAddressKind.IPv4, 205, now), + }); + + // Instance 视图(D3 case 2):仅 Active 与 Draining 可解析;Booting/Stopped/Removed 一律拒绝。 + Assert.True(table.TryGetInstance("game-active-1", out _)); + Assert.True(table.TryGetInstance("game-draining-1", out _)); + Assert.False(table.TryGetInstance("game-booting-1", out _)); + Assert.False(table.TryGetInstance("game-stopped-1", out _)); + Assert.False(table.TryGetInstance("game-removed-1", out _)); + + // Role 视图(D3 case 3):仅 Active 进入;Draining 不接新流量。 + var activeIds = table.GetActiveInstances("Game").Select(instance => instance.InstanceId).ToList(); + Assert.Equal(new List { "game-active-1" }, activeIds); + } +}