Ctrl K
ComunidadGuías…

health.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
	"sync"
	"sync/atomic"
	"time"
)

const (
	// healthInterval is how often to check that the bot can do its job.
	healthInterval = 5 * time.Minute
	// checkParallelism and checkTimeout keep a check of hundreds of channels
	// short without hitting the platforms' rate limits.
	checkParallelism = 8
	checkTimeout     = 30 * time.Second
)

// monitor checks that both platforms answer and the bot is still an admin of
// every channel. A passed check POSTs a heartbeat to HEARTBEAT_URL: when the
// heartbeats stop, the bot is down and the monitoring service raises an alarm.
// The admin is told only when the state changes, not every five minutes.
type monitor struct {
	relay         *relay
	sources       []*source
	to            []maxChat // every destination once
	maxSources    []*maxSource
	db            *store
	maxWebhookURL string
	tgWebhookURL  string
	tgEvents      []string
	maxEvents     []string
	heartbeat     string
	client        *http.Client
	failing       bool
	lastPoll      atomic.Int64 // Unix nanoseconds; initialized at startup for the first poll's grace period
}

func (m *monitor) run(ctx context.Context) {
	for {
		m.check(ctx)
		select {
		case <-ctx.Done():
			return
		case <-time.After(healthInterval):
		}
	}
}

func (m *monitor) check(ctx context.Context) {
	checks := make([]func(context.Context) error, 0, len(m.sources)+len(m.to))
	for _, src := range m.sources {
		checks = append(checks, func(ctx context.Context) error { return checkTelegramAdmin(ctx, m.relay.tg, src.channel) })
		if m.db != nil {
			checks = append(checks, func(ctx context.Context) error {
				return m.db.checkUncertain(ctx, channelKey{telegramPlatform, src.channel.ID})
			})
		}
	}
	for _, dst := range m.to {
		checks = append(checks, func(ctx context.Context) error { return m.relay.max.checkAdmin(ctx, dst) })
	}
	if len(m.maxSources) > 0 {
		checks = append(checks, func(ctx context.Context) error {
			return m.relay.max.webhook(ctx, m.maxWebhookURL, "", m.maxEvents, false)
		})
	}
	if len(m.sources) > 0 {
		checks = append(checks, func(ctx context.Context) error {
			return configureTelegramWebhook(ctx, m.relay.tg, m.tgWebhookURL, "", m.tgEvents, false)
		})
		if m.tgWebhookURL == "" {
			checks = append(checks, func(context.Context) error {
				if time.Since(time.Unix(0, m.lastPoll.Load())) > 2*healthInterval {
					return fmt.Errorf("приём постов Telegram не отвечает дольше %s", 2*healthInterval)
				}

				return nil
			})
		}
	}
	seen := map[int64]bool{}
	for _, src := range m.maxSources {
		checks = append(checks, func(ctx context.Context) error { return m.relay.max.checkRead(ctx, src.channel) })
		checks = append(checks, func(ctx context.Context) error {
			return m.db.checkUncertain(ctx, channelKey{maxPlatform, src.channel.id})
		})
		for _, dst := range src.to {
			if !seen[dst.ID] {
				checks = append(checks, func(ctx context.Context) error { return checkTelegramPost(ctx, m.relay.tg, dst) })
				seen[dst.ID] = true
			}
		}
	}
	problems := runChecks(ctx, checks, checkParallelism, checkTimeout)
	if ctx.Err() != nil {
		return // shutting down, not failing
	}

	switch {
	case len(problems) > 0:
		slog.Error("проверка не прошла", "problems", problems)
		if !m.failing {
			_ = m.relay.alert(ctx, "проверка не прошла, посты могут не пересылаться:\n— "+strings.Join(problems, "\n— "))
		}
		m.failing = true

		return
	case m.failing:
		slog.Info("проверка снова проходит")
		_ = m.relay.alert(ctx, "проверка снова проходит, посты пересылаются")
		m.failing = false
	}

	if m.heartbeat != "" {
		m.ping(ctx)
	}
}

func (s *store) checkUncertain(ctx context.Context, source channelKey) error {
	var blocked bool
	err := s.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM deliveries WHERE destination = ? AND state = 'uncertain')`, source.String()).Scan(&blocked)
	if err == nil && blocked {
		err = fmt.Errorf("приём постов %s приостановлен до проверки результата публикации", source)
	}

	return err
}

// runChecks runs the checks a few at a time, each with its own timeout, and
// returns the failures in the order of the checks.
func runChecks(ctx context.Context, checks []func(context.Context) error, parallel int, timeout time.Duration) []string {
	errs := make([]error, len(checks))
	slots := make(chan struct{}, parallel)
	var wg sync.WaitGroup
	for i, check := range checks {
		wg.Go(func() {
			slots <- struct{}{}
			defer func() { <-slots }()

			ctx, cancel := context.WithTimeout(ctx, timeout)
			defer cancel()
			errs[i] = check(ctx)
		})
	}
	wg.Wait()

	var problems []string
	for _, err := range errs {
		if err != nil {
			problems = append(problems, err.Error())
		}
	}

	return problems
}

// ping is a POST without a body: both GlitchTip and healthchecks.io accept it.
func (m *monitor) ping(ctx context.Context) {
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.heartbeat, nil)
	if err != nil {
		slog.Error("HEARTBEAT_URL не разобрать", "err", err)

		return
	}
	resp, err := m.client.Do(req)
	if err != nil {
		slog.Warn("пульс не отправлен", "err", withoutURL(err))

		return
	}
	_ = resp.Body.Close()
	if resp.StatusCode >= 300 {
		slog.Warn("пульс не принят", "status", resp.Status)
	}
}