chore: 并入 iotlcm-bot 历史到 bot/(原提交 31dbc1d)
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user