package model import ( "context" "gorm.io/gorm" "gorm.io/gorm/clause" ) // DetectionSchedule survives task-history cleanup and is never projected directly // to public APIs. Only automatic results advance this channel/kind clock. type DetectionSchedule struct { ChannelID int `gorm:"primaryKey;autoIncrement:false"` Kind string `gorm:"primaryKey;type:varchar(24)"` LastValid string `gorm:"type:varchar(16)"` IntervalMinutes int NextDueAt int64 LastFinishedAt int64 LastResultID string `gorm:"type:varchar(64)"` } func NextDetectionInterval(lastValid, outcome string, current int) int { if outcome != "normal" && outcome != "degraded" { return 15 } if lastValid == "" { return 30 } if lastValid != outcome { return 15 } if current == 15 { return 30 } return 60 } func EnsureDetectionSchedule(ctx context.Context, seed DetectionSchedule) error { return DB.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&seed).Error } func ApplyDetectionScheduleResult(ctx context.Context, channelID int, kind, resultID string, finishedAt int64, outcome string) error { return DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var state DetectionSchedule if err := lockForUpdate(tx).Where("channel_id = ? AND kind = ?", channelID, kind).First(&state).Error; err != nil { return err } // A result is committed before its clock advances. Reconciliation can // safely replay it after a crash, without advancing the clock twice. if finishedAt <= state.LastFinishedAt || resultID == state.LastResultID { return nil } state.IntervalMinutes = NextDetectionInterval(state.LastValid, outcome, state.IntervalMinutes) if outcome == "normal" || outcome == "degraded" { state.LastValid = outcome } state.LastFinishedAt, state.LastResultID = finishedAt, resultID state.NextDueAt = finishedAt + int64(state.IntervalMinutes)*60 return tx.Save(&state).Error }) }