package db import ( "fmt" "strings" "time" ) const subnetSpreadFailureThreshold = 5 const subnetSpreadPauseDuration = 24 * time.Hour // SubnetSpreadPause tracks /24 spread failure counts and temporary pauses. type SubnetSpreadPause struct { Prefix string `json:"prefix"` FailCount int `json:"fail_count"` PausedUntil *time.Time `json:"paused_until,omitempty"` UpdatedAt time.Time `json:"updated_at"` } func (d *Database) ensureSubnetSpreadPauseTable() error { if d == nil { return nil } _, err := d.Exec(`CREATE TABLE IF NOT EXISTS subnet_spread_pause ( prefix TEXT PRIMARY KEY, fail_count INTEGER NOT NULL DEFAULT 0, paused_until DATETIME, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`) if err != nil { return fmt.Errorf("subnet_spread_pause migration: %w", err) } _, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_subnet_spread_pause_until ON subnet_spread_pause(paused_until)`) return nil } // RecordSubnetSpreadFailure increments failures for a /24 prefix; pauses at threshold. func (d *Database) RecordSubnetSpreadFailure(prefix string) (paused bool, err error) { if err := d.ensureSubnetSpreadPauseTable(); err != nil { return false, err } prefix = normalizeSubnetPrefix(prefix) if prefix == "" { return false, fmt.Errorf("subnet prefix required") } now := time.Now().UTC() var failCount int err = d.QueryRow(`SELECT fail_count FROM subnet_spread_pause WHERE prefix = ?`, prefix).Scan(&failCount) if err != nil { _, err = d.Exec( `INSERT INTO subnet_spread_pause (prefix, fail_count, paused_until, updated_at) VALUES (?, 1, NULL, ?)`, prefix, now, ) return false, err } failCount++ var pausedUntil *time.Time if failCount >= subnetSpreadFailureThreshold { until := now.Add(subnetSpreadPauseDuration) pausedUntil = &until paused = true } if pausedUntil != nil { _, err = d.Exec( `UPDATE subnet_spread_pause SET fail_count = ?, paused_until = ?, updated_at = ? WHERE prefix = ?`, failCount, pausedUntil, now, prefix, ) } else { _, err = d.Exec( `UPDATE subnet_spread_pause SET fail_count = ?, updated_at = ? WHERE prefix = ?`, failCount, now, prefix, ) } return paused, err } // IsSubnetSpreadPaused reports whether spread commands to prefix are blocked. func (d *Database) IsSubnetSpreadPaused(prefix string) (bool, error) { if err := d.ensureSubnetSpreadPauseTable(); err != nil { return false, err } prefix = normalizeSubnetPrefix(prefix) if prefix == "" { return false, nil } var pausedUntil *time.Time err := d.QueryRow(`SELECT paused_until FROM subnet_spread_pause WHERE prefix = ?`, prefix).Scan(&pausedUntil) if err != nil { return false, nil } if pausedUntil == nil { return false, nil } until := pausedUntil.UTC() if time.Now().UTC().Before(until) { return true, nil } _, _ = d.Exec(`UPDATE subnet_spread_pause SET paused_until = NULL, updated_at = ? WHERE prefix = ?`, time.Now().UTC(), prefix) return false, nil } // GetSubnetSpreadPause returns the pause row for UI / tests. func (d *Database) GetSubnetSpreadPause(prefix string) (*SubnetSpreadPause, error) { if err := d.ensureSubnetSpreadPauseTable(); err != nil { return nil, err } prefix = normalizeSubnetPrefix(prefix) if prefix == "" { return nil, nil } row := &SubnetSpreadPause{Prefix: prefix} var pausedRaw *time.Time err := d.QueryRow( `SELECT fail_count, paused_until, updated_at FROM subnet_spread_pause WHERE prefix = ?`, prefix, ).Scan(&row.FailCount, &pausedRaw, &row.UpdatedAt) if err != nil { return nil, nil } row.PausedUntil = pausedRaw return row, nil } func normalizeSubnetPrefix(prefix string) string { prefix = strings.TrimSpace(prefix) prefix = strings.TrimSuffix(prefix, ".0/24") prefix = strings.TrimSuffix(prefix, "/24") return prefix }