package detect import ( "context" "encoding/json" "fmt" "time" "github.com/redis/go-redis/v9" ) const ( // streakTTL is the Redis TTL for a per-node streak key. // // Must be comfortably wider than the longest streak window, which is the // confirmed threshold: 6 cycles × 5 min = 30 min. Two hours provides // enough margin to survive a process restart or a short leader-election gap // without zeroing out a valid streak mid-observation. streakTTL = 2 * time.Hour ) // Streak holds the consecutive-cycle counters for a single node. // All three counters are stored together under one Redis key to make the // read-modify-write loop atomic with a single GET + SET round-trip. type Streak struct { // FailStreak is the number of consecutive detection cycles in which the // node's domestic probes failed while the node was in "up" state. // It is reset to zero once the suspect threshold is reached (transition // attempted) or when domestic probes recover. FailStreak int `json:"fail_streak"` // SuspectStreak counts consecutive cycles spent in "blocked_suspect" where // the GFW-block condition (domestic fail + overseas OK) still holds. // Reaching ConfirmedStreakMin triggers the confirmed transition. SuspectStreak int `json:"suspect_streak"` // RecoverStreak counts consecutive cycles in "blocked_suspect" where // domestic probes pass. Reaching RecoverStreakMin triggers recovery. RecoverStreak int `json:"recover_streak"` } // StreakStore persists per-node Streak values in Redis. // // Key format: detect:streak:{nodeID} (JSON-encoded Streak, TTL 2 h) // // Missing-key semantics: a missing key means "no streak data" and is treated // as a zero Streak. After a process restart, the first Tick loads whichever // streaks survived in Redis and continues from that point — streaks are NOT // re-derived from probe history on restart, which keeps the restart path // simple at the cost of a potential single-cycle window of uncertainty. type StreakStore struct { rdb *redis.Client } // NewStreakStore creates a StreakStore backed by the given Redis client. func NewStreakStore(rdb *redis.Client) *StreakStore { return &StreakStore{rdb: rdb} } // streakKey returns the Redis key for nodeID's streak data. func streakKey(nodeID string) string { return "detect:streak:" + nodeID } // Load reads the current Streak for nodeID. // A missing or corrupted key returns a zero Streak without error. func (s *StreakStore) Load(ctx context.Context, nodeID string) (Streak, error) { val, err := s.rdb.Get(ctx, streakKey(nodeID)).Result() if err == redis.Nil { return Streak{}, nil } if err != nil { return Streak{}, fmt.Errorf("detect: streak get %s: %w", nodeID, err) } var sk Streak if err := json.Unmarshal([]byte(val), &sk); err != nil { // Corrupted entry: treat as zero (non-fatal; we overwrite on the next Save). return Streak{}, nil } return sk, nil } // Save persists sk for nodeID with the standard streakTTL. func (s *StreakStore) Save(ctx context.Context, nodeID string, sk Streak) error { data, err := json.Marshal(sk) if err != nil { return fmt.Errorf("detect: streak marshal %s: %w", nodeID, err) } if err := s.rdb.Set(ctx, streakKey(nodeID), data, streakTTL).Err(); err != nil { return fmt.Errorf("detect: streak set %s: %w", nodeID, err) } return nil } // Reset deletes the streak key for nodeID (e.g. after a confirmed transition). func (s *StreakStore) Reset(ctx context.Context, nodeID string) error { if err := s.rdb.Del(ctx, streakKey(nodeID)).Err(); err != nil { return fmt.Errorf("detect: streak del %s: %w", nodeID, err) } return nil }