Files
iotlcm/bot/internal/notify/health.go

119 lines
4.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package notify
import (
"fmt"
"os"
"time"
)
// Health 描述归档目录的可用性。
//
// 背景:归档目录通过 bind mount 进容器后,宿主机一旦把它删除,容器里的路径
// 会变成 mountinfo 中带 "//deleted" 的空壳目录(硬链接数 nlink=0),读起来
// "存在但为空",bot 会静默地再也发不出任何通知 —— 2026-10-07 预演环境实际
// 发生过(宿主日志清理脚本删空归档目录,bot 从 10-07 静默到 10-12)。
// 这里把该状态显式暴露出来,用于日志与飞书告警。
type Health struct {
Mounted bool
Nlink uint64
Entries int
Degraded bool
Reason string
}
// StaleAfter 是「目录持续为空多久后开始高频复查」的阈值。
// 归档只在全量同步结束后写入,中间的空窗是正常的,因此不能一空就缩短周期。
const StaleAfter = 10 * time.Minute
// DegradedAlertInterval 是同一个失效状态重复告警的最小间隔。
const DegradedAlertInterval = 6 * time.Hour
// DegradedExitDelay 是检测到挂载失效后,退出前的等待时间。
//
// 为什么不立即退出:compose 的 restart 策略会立即拉起容器,若进程马上又检测到
// 失效并退出,就变成几秒一次的崩溃循环(实测 8 次重启只花了约 9 秒),既刷日志
// 又毫无意义。等一会再退,既给宿主一个修复窗口,也让重启频率可控。
// 实测:目录真的被删掉时,等几十秒后再重启依然能自愈(重启会重新解析挂载)。
//
// 声明为变量便于测试缩短(生产用默认值)。
var DegradedExitDelay = 30 * time.Second
// DegradedMaxExits 是同一进程内连续退出的上限,防止极端情况下无限退出。
// 超过后不再退出,只保留告警与日志,避免变成无意义的重启风暴。
const DegradedMaxExits = 3
// ExitOnDegraded 为 true 时,检测到挂载失效(目录被宿主删除,nlink=0)
// 就告警并退出进程,交给 restart policy 拉起。
//
// 为什么退出就能修好:bind mount 的源路径是**容器启动时**解析的。宿主把目录删掉
// 再重建后,容器仍指向旧 inode(mountinfo 里带 //deleted、nlink=0),看不到任何
// 新文件;但重新启动容器会重新解析路径,拿到新 inode 并立即恢复正常。
// 实测(2026-10-13,dev 100.29):手工 docker restart 后 inode 从旧变新、文件立即可见;
// 进程自行退出后由 restart=unless-stopped 拉起,同样自愈(restarts=1)。
//
// 这样就不再需要「重建 worker 必须同批重建 bot」这种人工纪律:
// worker 重建把目录换掉后,bot 自己会在下一轮扫描退出并被拉起,自动重新挂载。
var ExitOnDegraded = true
// Health 检查归档目录当前是否可用。
func (s Service) Health() Health {
h := Health{Mounted: true}
info, err := os.Stat(s.archiveDir)
if err != nil {
h.Mounted = false
h.Degraded = true
h.Reason = fmt.Sprintf("archive directory is not readable: %v", err)
return h
}
if !info.IsDir() {
h.Mounted = false
h.Degraded = true
h.Reason = fmt.Sprintf("archive path is not a directory: %s", s.archiveDir)
return h
}
if nlink, ok := linkCount(info); ok {
h.Nlink = nlink
if nlink == 0 {
// 宿主机目录已被删除,容器仍挂着它的空壳。
h.Mounted = false
h.Degraded = true
h.Reason = fmt.Sprintf("archive directory was deleted on the host (nlink=0); recreate this container to rebind %s", s.archiveDir)
return h
}
}
entries, err := os.ReadDir(s.archiveDir)
if err != nil {
h.Mounted = false
h.Degraded = true
h.Reason = fmt.Sprintf("archive directory is not listable: %v", err)
return h
}
h.Entries = len(entries)
if h.Entries == 0 {
h.Reason = "archive directory is empty"
}
return h
}
// Interval 返回下一次扫描前的等待时长。
//
// 正常情况下保持配置的轮询周期;只有在目录长期为空(可能正被写入,也可能已失效)
// 时才缩短到 10s,避免把通知延迟留给下一次发布。
func (s Service) Interval(emptySince time.Time) time.Duration {
base := s.pollInterval
if base <= 0 {
base = 10 * time.Second
}
if base <= 10*time.Second {
return base
}
h := s.Health()
if h.Entries > 0 {
return base
}
if emptySince.IsZero() || time.Since(emptySince) < StaleAfter {
return base
}
return 10 * time.Second
}