// Package healthcheck 定义 room-service 的 gRPC health 探针状态和依赖检查。 package healthcheck import ( "context" "sync/atomic" "time" "hyapp/pkg/grpchealth" ) const ( // serviceName 是标准 gRPC health 对外暴露的服务名。 serviceName = "room-service" // readyBudget 限制 ready 总耗时,避免探针堆积。 readyBudget = 500 * time.Millisecond // mysqlBudget 限制 MySQL ping 时间,MySQL 不可用时不能接新房间命令。 mysqlBudget = 300 * time.Millisecond // redisBudget 限制 Redis ping 和 lease probe 时间。 redisBudget = 200 * time.Millisecond // redisLeaseTTL 是健康检查 lease 探针使用的短 TTL。 redisLeaseTTL = 2 * time.Second ) // RuntimeProbe 覆盖 Room Cell owner、repository 和 directory 等进程内装配状态。 type RuntimeProbe interface { // HealthCheck 只检查进程内对象是否装配完整,不做慢 IO。 HealthCheck() error } // MySQLPinger 抽象 MySQL repository 的轻量连通性检查。 type MySQLPinger interface { // Ping 验证 MySQL 连接池可用,调用方负责传入短 deadline。 Ping(ctx context.Context) error } // RedisProbe 抽象 Redis 当前可访问性和 lease Lua 原子路径探测。 type RedisProbe interface { // Ping 验证 Redis 基础连通性。 Ping(ctx context.Context) error // ProbeLease 验证 Redis lease Lua 原子路径可用。 ProbeLease(ctx context.Context, nodeID string, now time.Time, ttl time.Duration) error } // State 记录 room-service readiness 所需的进程状态和硬依赖。 type State struct { // nodeID 是当前 room-service 实例标识,Redis lease 探针需要它。 nodeID string // runtime 是领域服务的进程内装配状态。 runtime RuntimeProbe // mysql 是持久化连通性检查对象。 mysql MySQLPinger // redis 是路由目录连通性和 lease 脚本检查对象。 redis RedisProbe // grpcActive 表示 gRPC listener 已进入 Serve 循环。 grpcActive atomic.Bool // draining 表示节点正在下线,ready 必须失败以停止接新命令和接管。 draining atomic.Bool } // NewState 创建 room-service 健康状态所有者。 func NewState(nodeID string, runtime RuntimeProbe, mysql MySQLPinger, redis RedisProbe) *State { return &State{ nodeID: nodeID, runtime: runtime, mysql: mysql, redis: redis, } } // ServiceName 返回标准 gRPC health 使用的服务边界名。 func (s *State) ServiceName() string { return serviceName } // MarkGRPCServing 标记 gRPC listener 已进入 Serve 循环。 func (s *State) MarkGRPCServing() { s.grpcActive.Store(true) } // MarkGRPCStopped 标记 gRPC listener 已退出 Serve 循环。 func (s *State) MarkGRPCStopped() { s.grpcActive.Store(false) } // MarkDraining 让 ready 立即失败,避免下线节点继续接管新房间。 func (s *State) MarkDraining() { s.draining.Store(true) } // Live 检查本进程关键内存结构,不把 MySQL/Redis 抖动映射为 live 失败。 func (s *State) Live() []grpchealth.Check { // live 只判断进程是否应该继续存在,不因为 MySQL/Redis 短抖动重启进程。 checks := []grpchealth.Check{ grpchealth.Bool("grpc_server", s.grpcActive.Load(), "gRPC listener is not serving"), } checks = append(checks, s.runtimeCheck()) return checks } // Ready 检查 room-service 接新命令所需的硬依赖。 func (s *State) Ready(ctx context.Context) []grpchealth.Check { // ready 是流量入口判定,必须包含持久化、Redis lease 和 drain 状态。 ctx, cancel := context.WithTimeout(ctx, readyBudget) defer cancel() checks := []grpchealth.Check{ grpchealth.Bool("grpc_listener", s.grpcActive.Load(), "gRPC listener is not serving"), grpchealth.Bool("draining", !s.draining.Load(), "node is draining"), } checks = append(checks, s.runtimeCheck()) checks = append(checks, s.mysqlCheck(ctx)) checks = append(checks, s.redisPingCheck(ctx)) checks = append(checks, s.redisLeaseCheck(ctx)) return checks } func (s *State) runtimeCheck() grpchealth.Check { if s.runtime == nil { // runtime 缺失说明 App.New 装配异常。 return grpchealth.Fail("room_runtime", "runtime probe is not initialized") } if err := s.runtime.HealthCheck(); err != nil { // 进程内核心对象缺失时不能处理任何房间命令。 return grpchealth.Fail("room_runtime", err.Error()) } return grpchealth.OK("room_runtime") } func (s *State) mysqlCheck(ctx context.Context) grpchealth.Check { if s.mysql == nil { // 没有 MySQL repository 时无法保存 command log/snapshot/outbox。 return grpchealth.Fail("mysql", "MySQL repository is not initialized") } checkCtx, cancel := context.WithTimeout(ctx, mysqlBudget) defer cancel() if err := s.mysql.Ping(checkCtx); err != nil { // MySQL 不可达时即使内存 Cell 存在,也不能安全接新写命令。 return grpchealth.Fail("mysql", err.Error()) } return grpchealth.OK("mysql") } func (s *State) redisPingCheck(ctx context.Context) grpchealth.Check { if s.redis == nil { // 没有 Redis directory 时无法保证单房间单活。 return grpchealth.Fail("redis_ping", "Redis probe is not initialized") } checkCtx, cancel := context.WithTimeout(ctx, redisBudget) defer cancel() if err := s.redis.Ping(checkCtx); err != nil { // Redis 不可达时新房间接管和续租语义不可用。 return grpchealth.Fail("redis_ping", err.Error()) } return grpchealth.OK("redis_ping") } func (s *State) redisLeaseCheck(ctx context.Context) grpchealth.Check { if s.redis == nil { // lease 探针和 ping 分开,方便定位是连通性还是 Lua 原子路径问题。 return grpchealth.Fail("redis_lease", "Redis probe is not initialized") } checkCtx, cancel := context.WithTimeout(ctx, redisBudget) defer cancel() if err := s.redis.ProbeLease(checkCtx, s.nodeID, time.Now().UTC(), redisLeaseTTL); err != nil { // Eval/cjson/写权限异常会直接破坏 owner 仲裁,ready 必须失败。 return grpchealth.Fail("redis_lease", err.Error()) } return grpchealth.OK("redis_lease") }