119 lines
4.3 KiB
Go
119 lines
4.3 KiB
Go
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
|
||
}
|