Files
EvoBGP/internal/scheduler/run.go
T
DenozordecandCursor 8fe74c1d3b feat(jobs): add durable PG queue reclaim, slog, and richer metrics
JSON slog в ключевых пакетах; Prometheus path_group, job_audit_depth, upstream breaker; job_audit ClaimQueued/ReclaimStaleRunning + Adopt loop для HA после рестарта.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 12:26:18 +07:00

136 lines
4.3 KiB
Go

// Package scheduler drives module refresh intervals and enqueues tenant_refresh jobs on the shared Registry.
package scheduler
import (
"bytes"
"context"
"encoding/json"
"evobgp/internal/logging"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"evobgp/internal/broker"
"evobgp/internal/config"
"evobgp/internal/httpclient"
"evobgp/internal/jobs"
"evobgp/internal/pipeline"
"evobgp/internal/store"
)
// Deps wires the scheduler to the store. Use either Jobs (same process as API, e.g. evobgp-all) or
// APIBase+APIToken to call the control plane over HTTP (separate containers in reference Compose).
type Deps struct {
Store store.Backend
Jobs *jobs.Registry
APIBase string // e.g. http://evobgp-api:8080
APIToken string // Bearer token (operator/editor)
HTTP *http.Client
}
// Run blocks until ctx is cancelled. Misconfigured deps terminate the process (no idle fallback).
func Run(ctx context.Context, deps *Deps) {
cfg := config.Load()
broker.LogConnect(ctx, cfg.BrokerURL)
if deps == nil || deps.Store == nil {
log.Fatalf("evobgp-scheduler: missing store (pass scheduler.Deps with Store from BootstrapWorkers or evobgp-all)")
}
if deps.Jobs == nil && (strings.TrimSpace(deps.APIBase) == "" || strings.TrimSpace(deps.APIToken) == "") {
log.Fatalf("evobgp-scheduler: need either in-process Jobs (evobgp-all) or both EVOBGP_CONTROL_PLANE_URL and EVOBGP_SCHEDULER_BEARER")
}
t := time.NewTicker(30 * time.Second)
defer t.Stop()
if deps.Jobs != nil {
logging.Default().Info(fmt.Sprintf("evobgp-scheduler: active (in-process enqueue tenant_refresh)"))
} else {
logging.Default().Info(fmt.Sprintf("evobgp-scheduler: active (HTTP POST .../tenant/refresh → %s)", strings.TrimSpace(deps.APIBase)))
}
for {
select {
case <-ctx.Done():
logging.Default().Info(fmt.Sprintf("evobgp-scheduler: stopped"))
return
case <-t.C:
tick(context.Background(), deps)
}
}
}
func tick(ctx context.Context, deps *Deps) {
tenants, err := deps.Store.ListTenantIDs()
if err != nil {
logging.Default().Info(fmt.Sprintf("evobgp-scheduler: list tenants: %v", err))
return
}
now := time.Now().UTC()
tickBucket := now.Unix() / pipeline.SchedulerTickSec
for _, tid := range tenants {
var due []string
for _, mod := range deps.Store.ListModules(tid) {
if pipeline.ModuleDueForScheduler(mod, now) {
due = append(due, mod.ID)
}
}
if len(due) == 0 {
continue
}
key := fmt.Sprintf("sched-tenant-%s-%d", tid, tickBucket)
if deps.Jobs != nil {
ids := append([]string(nil), due...)
_, created, err := deps.Jobs.Enqueue(tid, jobs.KindTenantRefresh, &key, nil, map[string]any{
"module_ids": ids,
"trigger": "scheduler",
})
if err != nil {
logging.Default().Info(fmt.Sprintf("evobgp-scheduler: enqueue tenant %s: %v", tid, err))
continue
}
if created {
logging.Default().Info(fmt.Sprintf("evobgp-scheduler: queued tenant refresh for %d module(s) in tenant %s", len(due), tid))
}
continue
}
if err := postTenantRefresh(ctx, deps, due, key); err != nil {
logging.Default().Info(fmt.Sprintf("evobgp-scheduler: http tenant refresh %s: %v", tid, err))
}
}
}
func postTenantRefresh(ctx context.Context, deps *Deps, moduleIDs []string, idempotencyKey string) error {
base := strings.TrimRight(strings.TrimSpace(deps.APIBase), "/")
u := base + "/v1/tenant/refresh"
body, err := json.Marshal(map[string]any{"module_ids": moduleIDs})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
if err != nil {
return err
}
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(body)), nil
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(deps.APIToken))
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
hc := deps.HTTP
if hc == nil {
hc = httpclient.New(httpclient.DefaultTimeout)
}
resp, err := httpclient.DoWithRetry(ctx, hc, req, 3)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted {
return nil
}
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b)))
}