Files
iotlcm/internal/notify/service.go
T
2026-10-29 14:19:44 +08:00

351 lines
11 KiB
Go
Raw 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 (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"time"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/render"
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/state"
)
type Service struct {
archiveDir, environment string
stateDir string
pollInterval time.Duration
store state.Store
client Sender
logger *slog.Logger
// warned 记录已经报过错的归档,避免坏文件每轮扫描都刷一条日志。
// 键为「路径 + mtime + 大小」:文件一旦被重写就会重新告警。
warned map[string]struct{}
// transient 记录临时错误的重报节奏与累计次数。
// 临时错误必须持续可见(一次网络抖动不能被吞掉),但 bot 被移出群这类
// 长期故障不能每秒刷一条,否则日志被淹、真告警失联。
transient map[string]*transientWarn
}
// Sender 是发送端抽象:*feishu.Client 满足它,测试可注入替身。
type Sender interface {
Send(ctx context.Context, card []byte) error
SendText(ctx context.Context, text string) error
}
func New(archiveDir, environment string, pollInterval time.Duration, store state.Store, client Sender, logger *slog.Logger) Service {
if logger == nil {
logger = DefaultLogger()
}
return Service{archiveDir: archiveDir, environment: environment, stateDir: store.Dir(), pollInterval: pollInterval, store: store, client: client, logger: logger, warned: map[string]struct{}{}, transient: map[string]*transientWarn{}}
}
func (s Service) Render(path string) (string, error) {
a, err := archive.Load(path)
if err != nil {
return "", err
}
return render.Text(a, s.environment), nil
}
func (s Service) SendFile(ctx context.Context, path string, force bool) error {
if err := s.store.Prepare(); err != nil {
return err
}
return s.process(ctx, path, force)
}
func (s Service) runFiles() ([]string, error) {
files, err := filepath.Glob(filepath.Join(s.archiveDir, "run-*.json"))
if err != nil {
return nil, err
}
sort.Strings(files)
return files, nil
}
// skipKey 生成归档文件的「内容版本」标识,用于去重告警。
// 读不到元数据时退化为路径本身,宁可少报警也不要刷屏。
func skipKey(path string) string {
info, err := os.Stat(path)
if err != nil {
return path
}
return fmt.Sprintf("%s|%d|%d", path, info.ModTime().UnixNano(), info.Size())
}
// archiveError 区分「这个归档永远不会成功」与「这次发送碰上了临时故障」。
// 前者(格式不对、旧版本、rounds 为空)是常态,只该报一次;
// 后者(飞书超时、鉴权失败、写状态失败)必须每轮重试并持续可见,
// 否则一次网络抖动就会被去重逻辑吞掉,通知静默丢失。
type archiveError struct {
err error
permanent bool
}
func (e *archiveError) Error() string { return e.err.Error() }
func (e *archiveError) Unwrap() error { return e.err }
// permanentErr 标记「重试也不会好」的错误。
func permanentErr(err error) error { return &archiveError{err: err, permanent: true} }
// isPermanent 判断错误是否属于「归档本身无效」。
func isPermanent(err error) bool {
var ae *archiveError
return errors.As(err, &ae) && ae.permanent
}
// warnOnce 对同一个归档文件的重报做节流。
//
// 永久错误(归档格式坏、旧版本)只记一次:重试也不会好。
// 临时错误(飞书超时、bot 不在群)按退避节奏重报:
// - 前 transientWarnFastPhase 次每次都报,保证瞬时抖动可见;
// - 之后按指数退避(1min→2min→4min…上限 transientWarnMaxInterval);
// - 超过 transientWarnMaxCount 次后只记一条汇总并停止重报,避免日志风暴。
//
// 历史上 230002(bot 不在群)持续 10 小时刷了 18,266 条 ERROR,占日志 99.9%,
// 把真正的告警完全淹没——这正是本函数要防的情况。
func (s Service) warnOnce(path string, err error) {
if !isPermanent(err) {
s.warnTransient(path, err)
return
}
key := skipKey(path)
if s.warned != nil {
if _, seen := s.warned[key]; seen {
return
}
s.warned[key] = struct{}{}
}
s.logger.Error("skip archive", "path", path, "error", err)
}
const (
// transientWarnFastPhase 是「每次都报」的次数,覆盖瞬时抖动。
transientWarnFastPhase = 5
// transientWarnMaxInterval 是退避重报的间隔上限。
transientWarnMaxInterval = 10 * time.Minute
// transientWarnMaxCount 是同一归档同一错误的最大重报次数,超过后静默。
transientWarnMaxCount = 30
)
// transientWarn 记录一个归档的临时错误重报状态。
type transientWarn struct {
count int
lastLog time.Time
suppressed bool
}
// warnTransient 按退避节奏记录临时错误。
func (s Service) warnTransient(path string, err error) {
key := skipKey(path)
now := time.Now()
if s.transient == nil {
s.transient = map[string]*transientWarn{}
}
st, ok := s.transient[key]
if !ok {
st = &transientWarn{}
s.transient[key] = st
}
if st.suppressed {
return
}
// 前 fastPhase 次每次都报。
if st.count < transientWarnFastPhase {
st.count++
st.lastLog = now
s.logger.Error("process archive failed", "path", path, "error", err, "attempt", st.count)
return
}
// 之后按指数退避。
interval := time.Minute << uint(st.count-transientWarnFastPhase)
if interval > transientWarnMaxInterval {
interval = transientWarnMaxInterval
}
if now.Sub(st.lastLog) < interval {
return
}
st.count++
st.lastLog = now
if st.count > transientWarnMaxCount {
st.suppressed = true
s.logger.Error("process archive failed, suppressing further identical alerts",
"path", path, "error", err, "attempts", st.count-1,
"hint", "this is likely a persistent configuration problem (e.g. bot removed from chat), fix the cause to resume notifications")
return
}
s.logger.Error("process archive failed", "path", path, "error", err, "attempt", st.count)
}
// Scan 扫描归档目录并发送尚未通知过的 Run。
//
// 单个归档出错(旧格式、写了一半、权限不足)只跳过并记日志:
// 过去这里直接 return,一个坏文件会挡住后面所有归档,通知会静默丢失。
// 坏文件是常态(例如 manual 部分同步的 Run 天然没有 rounds),所以只记一次。
func (s Service) Scan(ctx context.Context) error {
if err := s.store.Prepare(); err != nil {
return err
}
files, err := s.runFiles()
if err != nil {
return err
}
for _, path := range files {
if err := s.process(ctx, path, false); err != nil {
s.warnOnce(path, err)
}
}
return nil
}
func (s Service) Watch(ctx context.Context) error {
if s.pollInterval <= 0 {
s.pollInterval = 10 * time.Second
}
var emptySince time.Time
for {
h := s.Health()
if h.Degraded {
// 目录已被宿主删除等硬故障:告警节流状态落盘,避免退出重启后
// 把同一次故障反复推送成飞书骚扰。
now := time.Now()
st := loadAlertState(s.stateDir)
if st.shouldAlert(h.Reason, now, DegradedAlertInterval) {
s.logger.Error("archive directory is not usable", "path", s.archiveDir, "reason", h.Reason, "entries", h.Entries)
s.alertMount(ctx, h.Reason, st.ExitCount+1)
st.LastDegradedAlert = now
st.LastDegradedReason = h.Reason
if err := saveAlertState(s.stateDir, st); err != nil {
s.logger.Error("save alert state failed", "error", err)
}
}
if ExitOnDegraded {
// 退出并交给 restart policy 重新拉起:重启会重新解析 bind mount,
// 从而重新挂载到宿主当前的目录(详见 ExitOnDegraded 注释)。
st.ExitCount++
if err := saveAlertState(s.stateDir, st); err != nil {
s.logger.Error("save alert state failed", "error", err)
}
if st.ExitCount > DegradedMaxExits {
// 已经反复重启仍未恢复(例如宿主把目录删了且不再重建):
// 继续退出只会变成重启风暴,改为留在原地持续观测+告警。
s.logger.Error("archive mount still unusable after repeated restarts, staying up to keep reporting",
"path", s.archiveDir, "reason", h.Reason, "exitCount", st.ExitCount)
} else {
// 等一会再退:restart 策略是立即拉起的,立刻退出会造成几秒一次的崩溃循环。
s.logger.Error("exit to rebind archive mount", "path", s.archiveDir, "reason", h.Reason, "exitCount", st.ExitCount, "delay", DegradedExitDelay)
timer := time.NewTimer(DegradedExitDelay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
return fmt.Errorf("archive directory unusable, restarting to rebind mount: %s", h.Reason)
}
}
} else if h.Entries == 0 {
if emptySince.IsZero() {
emptySince = time.Now()
}
} else {
emptySince = time.Time{}
}
if err := s.Scan(ctx); err != nil {
s.logger.Error("scan failed", "error", err)
}
timer := time.NewTimer(s.Interval(emptySince))
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
// alert 在飞书客户端可用时发一条文本告警;失败只记日志,不影响主循环。
func (s Service) alert(ctx context.Context, title, reason string) {
if s.client == nil {
return
}
text := fmt.Sprintf("[iotlcm-bot][%s] %s: %s", s.environment, title, reason)
if err := s.client.SendText(ctx, text); err != nil {
s.logger.Error("send alert failed", "error", err)
}
}
// alertMount 发送「归档目录不可用」告警卡片。
//
// 用卡片而非纯文本:text 消息在飞书里是灰底等宽块,长错误串会把气泡撑得很宽;
// 卡片带红色 header 与分区字段,一眼能看出是故障,且与同步结果卡片风格一致。
func (s Service) alertMount(ctx context.Context, reason string, exitCount int) {
if s.client == nil {
return
}
summary := "正在重启以重新挂载"
if exitCount > DegradedMaxExits {
summary = "反复重启仍未恢复,已改为持续监控"
}
card, err := render.AlertCard(render.Alert{
Environment: s.environment,
Title: "归档目录不可用",
Summary: summary,
Reason: render.FriendlyReason(reason),
ExitCount: exitCount,
Path: s.archiveDir,
})
if err != nil {
s.logger.Error("render alert card failed", "error", err)
return
}
if err := s.client.Send(ctx, card); err != nil {
s.logger.Error("send alert failed", "error", err)
}
}
func (s Service) process(ctx context.Context, path string, force bool) error {
a, err := archive.Load(path)
if err != nil {
// 归档本身无效(格式/版本/rounds 为空):重试也不会好。
return permanentErr(err)
}
if !force {
sent, err := s.store.Has(a.Run.RunID)
if err != nil {
return err
}
if sent {
return nil
}
}
card, err := render.Card(a, s.environment)
if err != nil {
return permanentErr(err)
}
if s.client == nil {
return fmt.Errorf("feishu client is not configured")
}
if err := s.client.Send(ctx, card); err != nil {
return err
}
if err := s.store.Mark(a.Run.RunID); err != nil {
return err
}
s.logger.Info("run notification sent", "runId", a.Run.RunID, "path", path)
return nil
}
func DefaultLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
}