Skip to content

Commit ec66c23

Browse files
committed
scheduler: split workload spec/status and harden churn guards
1 parent 394c200 commit ec66c23

9 files changed

Lines changed: 305 additions & 43 deletions

File tree

persys-scheduler/go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/google/uuid v1.6.0
77
github.com/hashicorp/vault/api v1.16.0
88
github.com/prometheus/client_golang v1.11.1
9+
github.com/redis/go-redis/v9 v9.19.0
910
github.com/sirupsen/logrus v1.6.0
1011
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0
1112
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1
@@ -54,7 +55,7 @@ require (
5455
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
5556
go.opentelemetry.io/otel/metric v1.40.0 // indirect
5657
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
57-
go.uber.org/atomic v1.7.0 // indirect
58+
go.uber.org/atomic v1.11.0 // indirect
5859
go.uber.org/multierr v1.6.0 // indirect
5960
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect
6061
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect

persys-scheduler/go.sum

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce
1010
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
1111
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
1212
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
13+
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
14+
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
15+
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
16+
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
1317
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
1418
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
1519
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
@@ -106,6 +110,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
106110
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
107111
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
108112
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
113+
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
114+
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
109115
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
110116
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
111117
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -159,6 +165,8 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
159165
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
160166
github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4=
161167
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
168+
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
169+
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
162170
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
163171
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
164172
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
@@ -176,6 +184,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
176184
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
177185
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
178186
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
187+
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
188+
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
179189
go.etcd.io/etcd/api/v3 v3.5.21 h1:A6O2/JDb3tvHhiIz3xf9nJ7REHvtEFJJ3veW3FbCnS8=
180190
go.etcd.io/etcd/api/v3 v3.5.21/go.mod h1:c3aH5wcvXv/9dqIw2Y810LDXJfhSYdHQ0vxmP3CCHVY=
181191
go.etcd.io/etcd/client/pkg/v3 v3.5.21 h1:lPBu71Y7osQmzlflM9OfeIV2JlmpBjqBNlLtcoBqUTc=
@@ -204,8 +214,9 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY
204214
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
205215
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
206216
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
207-
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
208217
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
218+
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
219+
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
209220
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
210221
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
211222
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=

persys-scheduler/internal/config/config.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ type Config struct {
2020

2121
// etcd / discovery
2222
EtcdEndpoints []string
23+
RedisAddr string
24+
RedisPassword string
25+
RedisDB int
26+
RedisReconcileTTL time.Duration
27+
RedisEventTTL time.Duration
28+
RedisEventMaxEntries int64
2329
Domain string
2430
AgentsDiscoveryDomain string
2531
SchedulerShardKey string
@@ -80,9 +86,15 @@ func Load(insecureFlag bool) (*Config, error) {
8086
GRPCAddr: envOr("PERSYS_GRPC_ADDR", "0.0.0.0"),
8187
GRPCPort: grpcPort,
8288
MetricsPort: metricsPort,
83-
ExternalIP: envOr("PERSYS_EXTERNAL_IP", ""),
89+
ExternalIP: envOr("PERSYS_EXTERNAL_IP", ""),
8490

8591
EtcdEndpoints: splitCSV(envOr("ETCD_ENDPOINTS", "localhost:2379")),
92+
RedisAddr: strings.TrimSpace(os.Getenv("REDIS_ADDR")),
93+
RedisPassword: strings.TrimSpace(os.Getenv("REDIS_PASSWORD")),
94+
RedisDB: envIntOr("REDIS_DB", 0),
95+
RedisReconcileTTL: envDurationOrFlexibleSeconds("REDIS_RECONCILE_TTL", 24*time.Hour),
96+
RedisEventTTL: envDurationOrFlexibleSeconds("REDIS_EVENT_TTL", 24*time.Hour),
97+
RedisEventMaxEntries: int64(envIntOr("REDIS_EVENT_MAX_ENTRIES", 1000)),
8698
Domain: envOr("DOMAIN", "persys.local"),
8799
AgentsDiscoveryDomain: envOr("AGENTS_DISCOVERY_DOMAIN", "agents.persys.cloud"),
88100
SchedulerShardKey: envOr("SCHEDULER_SHARD_KEY", "genesis"),

persys-scheduler/internal/metrics/metrics.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@ var (
109109
},
110110
[]string{"desired_state"},
111111
)
112+
stateStoreWritesTotal = prometheus.NewCounterVec(
113+
prometheus.CounterOpts{
114+
Namespace: "persys",
115+
Subsystem: "scheduler",
116+
Name: "state_store_writes_total",
117+
Help: "Number of scheduler state-store writes by category.",
118+
},
119+
[]string{"category"},
120+
)
112121
)
113122

114123
var defaultNodeStatuses = []string{"ready", "active", "notready", "unknown"}
@@ -132,6 +141,7 @@ func Register() {
132141
nodeStatusGauge,
133142
workloadStatusGauge,
134143
workloadDesiredGauge,
144+
stateStoreWritesTotal,
135145
)
136146

137147
for _, s := range defaultNodeStatuses {
@@ -146,6 +156,10 @@ func Register() {
146156
})
147157
}
148158

159+
func IncStateStoreWrite(category string) {
160+
stateStoreWritesTotal.WithLabelValues(category).Inc()
161+
}
162+
149163
func GRPCUnaryServerInterceptor() grpc.UnaryServerInterceptor {
150164
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
151165
start := time.Now()

persys-scheduler/internal/scheduler/reconciler.go

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -440,11 +440,8 @@ func (r *Reconciler) applyDesiredState(ctx context.Context, workload models.Work
440440
workload.Metadata[workloadReapplyRevisionKey] = strings.TrimSpace(workload.RevisionID)
441441
workload.Metadata["lastLaunchTime"] = now
442442

443-
workloadJSON, marshalErr := json.Marshal(workload)
444-
if marshalErr == nil {
445-
if err := r.scheduler.RetryableEtcdPut("/workloads/"+workload.ID, string(workloadJSON)); err != nil {
446-
return action, fmt.Errorf("persist apply metadata: %w", err)
447-
}
443+
if err := r.scheduler.saveWorkload(workload); err != nil {
444+
return action, fmt.Errorf("persist apply metadata: %w", err)
448445
}
449446

450447
return action, nil
@@ -545,13 +542,29 @@ func (r *Reconciler) updateWorkloadReconciliationStatus(workloadID string, resul
545542
workload.Metadata["lastReconciliationSuccess"] = result.Success
546543
workload.Metadata["reconciliationRetryCount"] = result.RetryCount
547544

548-
workloadJSON, err := json.Marshal(workload)
549-
if err != nil {
550-
reconcilerLogger.WithError(err).WithField("workload_id", workloadID).Warn("failed to marshal workload during reconciliation status update")
545+
// High-churn reconciliation metadata should live in Redis when available.
546+
if r.scheduler.redisClient != nil {
547+
ttl := 24 * time.Hour
548+
if r.scheduler.cfg != nil && r.scheduler.cfg.RedisReconcileTTL > 0 {
549+
ttl = r.scheduler.cfg.RedisReconcileTTL
550+
}
551+
statusKey := fmt.Sprintf("workload:%s:reconcile_status", workloadID)
552+
if payload, mErr := json.Marshal(workload.Metadata); mErr == nil {
553+
if err := r.scheduler.redisClient.Set(context.Background(), statusKey, payload, ttl).Err(); err == nil {
554+
return
555+
}
556+
}
557+
}
558+
559+
currentLastAction, _ := workload.Metadata["last_action"].(string)
560+
if result.Action == "NoAction" && currentLastAction == "NoAction" {
551561
return
552562
}
553-
if err := r.scheduler.RetryableEtcdPut("/workloads/"+workloadID, string(workloadJSON)); err != nil {
563+
workload.Metadata["last_action"] = result.Action
564+
565+
if err := r.scheduler.saveWorkload(workload); err != nil {
554566
reconcilerLogger.WithError(err).WithField("workload_id", workloadID).Warn("failed to persist reconciliation status")
567+
return
555568
}
556569
}
557570

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package scheduler
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"time"
8+
9+
"github.com/persys-dev/persys-cloud/persys-scheduler/internal/logging"
10+
"github.com/redis/go-redis/v9"
11+
"github.com/sirupsen/logrus"
12+
)
13+
14+
var redisLogger = logging.C("scheduler.redis")
15+
16+
func (s *Scheduler) initRedisStore() {
17+
if s.cfg == nil || s.cfg.RedisAddr == "" {
18+
return
19+
}
20+
client := redis.NewClient(&redis.Options{
21+
Addr: s.cfg.RedisAddr,
22+
Password: s.cfg.RedisPassword,
23+
DB: s.cfg.RedisDB,
24+
})
25+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
26+
defer cancel()
27+
if err := client.Ping(ctx).Err(); err != nil {
28+
redisLogger.WithError(err).Warn("redis configured but unavailable; falling back to etcd for reconciliation telemetry")
29+
_ = client.Close()
30+
return
31+
}
32+
s.redisClient = client
33+
redisLogger.WithField("addr", s.cfg.RedisAddr).Info("redis telemetry store enabled")
34+
}
35+
36+
func (s *Scheduler) writeReconciliationTelemetry(workloadID, action string, success bool, reason string, attemptedAt time.Time) {
37+
rec := map[string]interface{}{
38+
"workloadId": workloadID,
39+
"action": action,
40+
"success": success,
41+
"reason": reason,
42+
"attemptedAt": attemptedAt.UTC().Format(time.RFC3339Nano),
43+
}
44+
payload, err := json.Marshal(rec)
45+
if err != nil {
46+
return
47+
}
48+
if s.redisClient != nil {
49+
ttl := 24 * time.Hour
50+
if s.cfg != nil && s.cfg.RedisReconcileTTL > 0 {
51+
ttl = s.cfg.RedisReconcileTTL
52+
}
53+
key := fmt.Sprintf("reconciliation:%s", workloadID)
54+
historyKey := "reconciliation:history"
55+
if err := s.redisClient.Set(context.Background(), key, payload, ttl).Err(); err == nil {
56+
maxEntries := int64(2000)
57+
if s.cfg != nil && s.cfg.RedisEventMaxEntries > 0 {
58+
maxEntries = s.cfg.RedisEventMaxEntries
59+
}
60+
pipe := s.redisClient.TxPipeline()
61+
pipe.LPush(context.Background(), historyKey, payload)
62+
pipe.LTrim(context.Background(), historyKey, 0, maxEntries-1)
63+
pipe.Expire(context.Background(), historyKey, ttl)
64+
_, _ = pipe.Exec(context.Background())
65+
return
66+
}
67+
redisLogger.WithError(err).WithFields(logrus.Fields{"key": key}).Warn("failed writing reconciliation telemetry to redis")
68+
}
69+
_ = s.RetryableEtcdPut(reconciliationKey(workloadID), string(payload))
70+
}
71+
72+
func (s *Scheduler) writeEventTelemetry(payload []byte) bool {
73+
if s.redisClient == nil {
74+
return false
75+
}
76+
ttl := 24 * time.Hour
77+
maxEntries := int64(2000)
78+
if s.cfg != nil {
79+
if s.cfg.RedisEventTTL > 0 {
80+
ttl = s.cfg.RedisEventTTL
81+
}
82+
if s.cfg.RedisEventMaxEntries > 0 {
83+
maxEntries = s.cfg.RedisEventMaxEntries
84+
}
85+
}
86+
pipe := s.redisClient.TxPipeline()
87+
pipe.LPush(context.Background(), "events:history", payload)
88+
pipe.LTrim(context.Background(), "events:history", 0, maxEntries-1)
89+
pipe.Expire(context.Background(), "events:history", ttl)
90+
_, err := pipe.Exec(context.Background())
91+
return err == nil
92+
}

0 commit comments

Comments
 (0)