diff options
Diffstat (limited to 'internal/peer.go')
| -rw-r--r-- | internal/peer.go | 145 |
1 files changed, 145 insertions, 0 deletions
diff --git a/internal/peer.go b/internal/peer.go new file mode 100644 index 0000000..bc0d12e --- /dev/null +++ b/internal/peer.go @@ -0,0 +1,145 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "time" +) + +type peerReport struct { + LastUpdated string `json:"lastUpdated"` +} + +func peerActive(ctx context.Context, conf config) (bool, string) { + if conf.PeerURL == "" { + return true, "Peer failover: disabled (PeerURL not set)" + } + + hostname, err := os.Hostname() + if err != nil { + return true, fmt.Sprintf("Peer failover: hostname lookup failed (%v); staying active", err) + } + + return peerActiveAt(ctx, conf, time.Now(), hostname, fetchPeerLastUpdated) +} + +func peerActiveAt( + ctx context.Context, + conf config, + now time.Time, + hostname string, + fetch func(context.Context, string) (time.Time, error), +) (bool, string) { + if conf.PeerURL == "" { + return true, "Peer failover: disabled (PeerURL not set)" + } + + primary := conf.PeerPrimaryName + if primary == "" { + primary = hostname + } + + secondary := conf.PeerSecondaryName + if secondary == "" { + if parsedURL, err := url.Parse(conf.PeerURL); err == nil && parsedURL.Hostname() != "" { + secondary = parsedURL.Hostname() + } + } + + if primary == "" || secondary == "" { + return true, "Peer failover: missing peer names; staying active" + } + + if hostname != primary && hostname != secondary { + return true, fmt.Sprintf("Peer failover: local hostname %s not in [%s, %s]; staying active", + hostname, primary, secondary) + } + + staleThresholdS := conf.PeerStaleThresholdS + if staleThresholdS == 0 { + staleThresholdS = 600 + } + + lastUpdated, err := fetch(ctx, conf.PeerURL) + if err != nil { + return true, fmt.Sprintf("Peer failover: peer check failed (%v); staying active", err) + } + + age := now.Sub(lastUpdated) + if age > time.Duration(staleThresholdS)*time.Second { + return true, fmt.Sprintf("Peer failover: peer stale (%v > %ds); staying active", + age, staleThresholdS) + } + + master := scheduledMaster(primary, secondary, now) + if hostname == master { + return true, fmt.Sprintf("Peer failover: peer healthy; scheduled master is %s", master) + } + + return false, fmt.Sprintf("Peer failover: peer healthy; scheduled master is %s", master) +} + +func scheduledMaster(primary, secondary string, now time.Time) string { + week := weekNumberSunday(now) + if week%2 == 0 { + return secondary + } + return primary +} + +// weekNumberSunday matches strftime %U (Sunday-based week number, 00-53). +func weekNumberSunday(t time.Time) int { + tUTC := t.In(time.UTC) + yearStart := time.Date(tUTC.Year(), 1, 1, 0, 0, 0, 0, time.UTC) + + // Find the first Sunday on or after Jan 1. + daysUntilSunday := (7 - int(yearStart.Weekday())) % 7 + firstSunday := yearStart.AddDate(0, 0, daysUntilSunday) + + if tUTC.Before(firstSunday) { + return 0 + } + + daysSinceFirstSunday := int(tUTC.Sub(firstSunday).Hours() / 24) + return 1 + (daysSinceFirstSunday / 7) +} + +func fetchPeerLastUpdated(ctx context.Context, peerURL string) (time.Time, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, peerURL, nil) + if err != nil { + return time.Time{}, err + } + + client := http.Client{ + Timeout: 5 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return time.Time{}, err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return time.Time{}, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var report peerReport + if err := json.NewDecoder(resp.Body).Decode(&report); err != nil { + return time.Time{}, err + } + if report.LastUpdated == "" { + return time.Time{}, fmt.Errorf("missing lastUpdated") + } + + lastUpdated, err := time.Parse(time.RFC3339, report.LastUpdated) + if err != nil { + return time.Time{}, err + } + + return lastUpdated, nil +} |
