Skip to content

Commit 692bc2c

Browse files
authored
feat: support redis cluster mode
feat: support redis cluster mode
2 parents 42dd80f + 004e517 commit 692bc2c

35 files changed

Lines changed: 1070 additions & 169 deletions

cmd/main.go

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
"github.com/VersusControl/versus-incident/pkg/teams"
2626
"github.com/aws/aws-sdk-go-v2/config"
2727
"github.com/aws/aws-sdk-go-v2/service/ssmincidents"
28-
"github.com/go-redis/redis/v8"
28+
"github.com/redis/go-redis/v9"
2929

3030
"github.com/VersusControl/versus-incident/pkg/common"
3131

@@ -145,13 +145,11 @@ func main() {
145145

146146
// Shared Redis client used by both on-call and the agent worker. We open
147147
// it once here so both subsystems share connections.
148-
var sharedRedis *redis.Client
148+
var sharedRedis redis.UniversalClient
149149

150150
if cfg.OnCall.Enable || cfg.OnCall.InitializedOnly {
151-
redisOptions := handlerRedisOptions(cfg.Redis)
152-
153-
// Initialize Redis client
154-
redisClient := redis.NewClient(redisOptions)
151+
// Initialize Redis client (cluster-aware when redis.cluster is set)
152+
redisClient := newRedisClient(cfg.Redis)
155153

156154
// Test Redis connection
157155
if err := redisClient.Ping(context.Background()).Err(); err != nil {
@@ -179,7 +177,7 @@ func main() {
179177
// in-memory cursors when Redis isn't reachable).
180178
rdb := sharedRedis
181179
if rdb == nil && cfg.Redis.Host != "" {
182-
rdb = redis.NewClient(handlerRedisOptions(cfg.Redis))
180+
rdb = newRedisClient(cfg.Redis)
183181
if err := rdb.Ping(context.Background()).Err(); err != nil {
184182
log.Printf("agent: Redis unavailable (%v); cursors will be in-memory only", err)
185183
rdb = nil
@@ -225,7 +223,7 @@ func main() {
225223
// startAgent constructs the worker, starts it in a goroutine, and registers
226224
// admin routes on the fiber app. It returns the catalog so the caller can
227225
// hold a reference (and so future hot-reload code has a handle to it).
228-
func startAgent(ctx context.Context, app *fiber.App, cfg c.AgentConfig, gatewaySecret string, store storage.Provider, rdb *redis.Client) (*agent.Catalog, error) {
226+
func startAgent(ctx context.Context, app *fiber.App, cfg c.AgentConfig, gatewaySecret string, store storage.Provider, rdb redis.UniversalClient) (*agent.Catalog, error) {
229227
// On the Postgres backend, install the typed signal-table
230228
// catalog store so the log catalog reads/writes the explicit
231229
// vs_patterns/vs_logs/vs_services tables (searchable, indexed) instead of
@@ -408,6 +406,24 @@ func handleQueueMessage(content *map[string]interface{}) error {
408406
return services.CreateIncident("", content, &overwrite) // teamID as empty string
409407
}
410408

409+
// newRedisClient builds the Redis client both subsystems share. It reuses
410+
// handlerRedisOptions for the addr/password/TLS settings, then returns either a
411+
// single-node client (the default) or a cluster-aware client when the operator
412+
// opts in via redis.cluster / REDIS_CLUSTER. Both concrete types satisfy
413+
// redis.UniversalClient, so callers thread the interface and single-key
414+
// Get/Set follow MOVED/ASK redirects transparently in cluster mode.
415+
func newRedisClient(rc c.RedisConfig) redis.UniversalClient {
416+
opts := handlerRedisOptions(rc)
417+
if rc.ClusterEnabled() {
418+
return redis.NewClusterClient(&redis.ClusterOptions{
419+
Addrs: []string{opts.Addr},
420+
Password: opts.Password,
421+
TLSConfig: opts.TLSConfig,
422+
})
423+
}
424+
return redis.NewClient(opts)
425+
}
426+
411427
func handlerRedisOptions(rc c.RedisConfig) *redis.Options {
412428
redisOptions := &redis.Options{
413429
Addr: rc.Host + ":" + strconv.Itoa(rc.Port),

cmd/main_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"testing"
55

66
c "github.com/VersusControl/versus-incident/pkg/config"
7+
"github.com/redis/go-redis/v9"
78
)
89

910
// TestHandlerRedisOptionsTLS covers the plaintext-Redis case: with TLS disabled the Redis
@@ -41,3 +42,35 @@ func TestHandlerRedisOptionsTLS(t *testing.T) {
4142
}
4243
})
4344
}
45+
46+
// TestNewRedisClientClusterType verifies that enabling cluster mode builds a
47+
// cluster-aware client (*redis.ClusterClient) rather than a single-node one.
48+
// The cluster client is what parses the Redis 7 / Valkey CLUSTER SLOTS reply
49+
// (which carries a 4th per-node metadata element) correctly, so cursor
50+
// persistence keeps working against ElastiCache in cluster mode instead of
51+
// falling back to in-memory. Both concrete clients are threaded as the shared
52+
// redis.UniversalClient interface, which the assertions below also confirm.
53+
func TestNewRedisClientClusterType(t *testing.T) {
54+
tru := true
55+
fls := false
56+
57+
t.Run("cluster enabled returns *redis.ClusterClient", func(t *testing.T) {
58+
client := newRedisClient(c.RedisConfig{Host: "localhost", Port: 6379, TLS: &fls, Cluster: &tru})
59+
defer client.Close()
60+
61+
if _, ok := client.(*redis.ClusterClient); !ok {
62+
t.Fatalf("expected *redis.ClusterClient when redis.cluster=true, got %T", client)
63+
}
64+
var _ redis.UniversalClient = client
65+
})
66+
67+
t.Run("cluster disabled returns single-node *redis.Client", func(t *testing.T) {
68+
client := newRedisClient(c.RedisConfig{Host: "localhost", Port: 6379, TLS: &fls})
69+
defer client.Close()
70+
71+
if _, ok := client.(*redis.Client); !ok {
72+
t.Fatalf("expected *redis.Client when cluster is off, got %T", client)
73+
}
74+
var _ redis.UniversalClient = client
75+
})
76+
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ require (
1616
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
1717
github.com/cloudwego/eino-ext/components/model/qwen v0.1.9
1818
github.com/go-git/go-git/v5 v5.19.1
19-
github.com/go-redis/redis/v8 v8.11.5
2019
github.com/gofiber/fiber/v2 v2.52.14
2120
github.com/google/uuid v1.6.0
2221
github.com/jackc/pgx/v5 v5.10.0
22+
github.com/redis/go-redis/v9 v9.21.0
2323
github.com/slack-go/slack v0.27.0
2424
github.com/spf13/viper v1.21.0
2525
golang.org/x/image v0.43.0
@@ -53,6 +53,7 @@ require (
5353
go.opentelemetry.io/otel v1.29.0 // indirect
5454
go.opentelemetry.io/otel/metric v1.29.0 // indirect
5555
go.opentelemetry.io/otel/trace v1.29.0 // indirect
56+
go.uber.org/atomic v1.11.0 // indirect
5657
golang.org/x/oauth2 v0.30.0 // indirect
5758
google.golang.org/api v0.197.0 // indirect
5859
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
@@ -131,7 +132,6 @@ require (
131132
github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect
132133
github.com/aws/smithy-go v1.27.3 // indirect
133134
github.com/cespare/xxhash/v2 v2.3.0 // indirect
134-
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
135135
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
136136
go.yaml.in/yaml/v3 v3.0.4 // indirect
137137
)

go.sum

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn
6464
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
6565
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
6666
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
67+
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
68+
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
69+
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
70+
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
6771
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
6872
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
6973
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
@@ -115,8 +119,6 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1
115119
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
116120
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
117121
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
118-
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
119-
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
120122
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
121123
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
122124
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -160,8 +162,6 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
160162
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
161163
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
162164
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
163-
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
164-
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
165165
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
166166
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
167167
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
@@ -260,14 +260,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
260260
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
261261
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
262262
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
263-
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
264-
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
265263
github.com/ollama/ollama v0.20.3 h1:oP7eJZ+U4FzkoGGdzBq9mbq4wyuNyvVkz/BWxOrrAc0=
266264
github.com/ollama/ollama v0.20.3/go.mod h1:tCX4IMV8DHjl3zY0THxuEkpWDZSOchJpzTuLACpMwFw=
267265
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
268266
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
269-
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
270-
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
271267
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
272268
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
273269
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
@@ -281,6 +277,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
281277
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
282278
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
283279
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
280+
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
281+
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
284282
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
285283
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
286284
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -359,6 +357,8 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM
359357
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
360358
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
361359
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
360+
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
361+
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
362362
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
363363
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
364364
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
@@ -371,6 +371,8 @@ go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2
371371
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
372372
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
373373
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
374+
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
375+
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
374376
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
375377
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
376378
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
@@ -475,7 +477,6 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
475477
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
476478
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
477479
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
478-
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
479480
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
480481
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
481482
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=

pkg/agent/brain_log.go

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -267,13 +267,13 @@ func (b *logBrain) Classify(obs core.Observation, mean, std float64, confident b
267267
prevCount := postCount - tickFreq // recover the pre-fold count
268268
prevVerdict := p.Verdict // Upsert never mutates Verdict, so == pre-fold
269269

270-
// AutoPromoteAfter ≤ 0 disables count-based promotion entirely ("0 disables
271-
// the promotion"): a pattern is never marked "known" by sighting count
272-
// alone, so it keeps flowing to detect-AI however often it is seen. The 100
273-
// default for an UNSET key is supplied by the embedded default_config layer
274-
// (loaded as the base before user overrides), so an omitted key arrives here
275-
// as 100 — only an explicit 0 (or negative) reaches the disabled branch. A
276-
// pattern already promoted to "known" stays known regardless of threshold.
270+
// The effective auto-promotion threshold gates count-based promotion: once
271+
// a pattern's sighting count reaches it, the pattern is marked "known" and
272+
// stops flowing to detect-AI on count alone. A non-positive configured
273+
// value is resolved to the default by isLogKnown (there is no way to turn
274+
// count-based promotion off), and an omitted key already arrives here as the
275+
// default via the config layer. A pattern already promoted to "known" stays
276+
// known regardless of threshold.
277277
threshold := b.cat.AutoPromoteAfter
278278
isKnown := isLogKnown(prevVerdict, postCount, threshold)
279279
if isKnown {
@@ -340,9 +340,9 @@ func (b *logBrain) confirmSpike(key string, res spikeResult) bool {
340340
// training this is what keeps the Verdict column and the readiness "To known"
341341
// column in agreement (both derive from isLogKnown).
342342
//
343-
// AutoPromoteAfter <= 0 disables count-based promotion entirely: for a not-yet-
344-
// known pattern isLogKnown then returns false, so Promote never marks a pattern
345-
// known by count in any mode; an operator-set "known" is untouched.
343+
// A non-positive AutoPromoteAfter is resolved to the default by isLogKnown, so
344+
// count-based promotion is always in effect; there is no "promotion disabled"
345+
// state.
346346
func (b *logBrain) Promote(key string) {
347347
p := b.catalog.Get(key)
348348
if p == nil {
@@ -353,16 +353,30 @@ func (b *logBrain) Promote(key string) {
353353
}
354354
}
355355

356+
// effectiveAutoPromote resolves the auto-promotion threshold used everywhere in
357+
// the learning engine. A non-positive value (an operator who omitted, blanked,
358+
// or zeroed the key, or a test that passes 0 directly) is treated as the
359+
// default rather than as a "promotion disabled" state — there is no way to turn
360+
// count-based promotion off. The config loader already normalizes the loaded
361+
// config to a positive value; this is the belt-and-suspenders guard so a 0 that
362+
// reaches the engine by any other path still resolves to a sane gate.
363+
func effectiveAutoPromote(n int) int {
364+
if n <= 0 {
365+
return config.DefaultAutoPromoteAfter
366+
}
367+
return n
368+
}
369+
356370
// isLogKnown is the SINGLE definition of log "known" — the exact predicate
357371
// logBrain.Classify gates promotion on. Both Classify and LogReadiness call it
358372
// so the classifier and the read-side readiness view can never drift; a drift
359373
// test pins them equal. A pattern is known when an operator (or a prior
360-
// auto-promotion) already marked it "known", OR count-based promotion is
361-
// enabled (autoPromoteAfter > 0) and the sighting count has reached it.
362-
// autoPromoteAfter <= 0 disables count-based promotion, so only a pre-set
363-
// "known" verdict counts.
374+
// auto-promotion) already marked it "known", OR the sighting count has reached
375+
// the effective auto-promotion threshold. A non-positive autoPromoteAfter is
376+
// resolved to the default (see effectiveAutoPromote), so count-based promotion
377+
// is always in effect.
364378
func isLogKnown(prevVerdict string, count, autoPromoteAfter int) bool {
365-
return prevVerdict == "known" || (autoPromoteAfter > 0 && count >= autoPromoteAfter)
379+
return prevVerdict == "known" || count >= effectiveAutoPromote(autoPromoteAfter)
366380
}
367381

368382
// LogReadiness computes the readiness of one log pattern as a generic
@@ -372,9 +386,9 @@ func isLogKnown(prevVerdict string, count, autoPromoteAfter int) bool {
372386
// gate compares) and the catalog's AutoPromoteAfter threshold.
373387
//
374388
// - Seen = the pattern's sighting count (the gate's own counter).
375-
// - Needed = autoPromoteAfter when count-promotion is enabled (>0); 0 is the
376-
// indeterminate sentinel when promotion is disabled (autoPromoteAfter<=0 →
377-
// manual-only).
389+
// - Needed = the effective auto-promotion threshold — always a positive gate
390+
// (a non-positive configured value is resolved to the default), so there is
391+
// no indeterminate/manual-only case.
378392
// - Ready = isLogKnown(...), so an already-"known"/"spike"-marked or
379393
// count-promoted pattern reports Ready.
380394
// - RatePerMin = the pattern's learned per-second sighting rate
@@ -387,12 +401,10 @@ func LogReadiness(p *Pattern, autoPromoteAfter int, pollInterval time.Duration)
387401
return core.Readiness{}
388402
}
389403
r := core.Readiness{
390-
Seen: p.Count,
391-
Ready: isLogKnown(p.Verdict, p.Count, autoPromoteAfter),
404+
Seen: p.Count,
405+
Needed: effectiveAutoPromote(autoPromoteAfter),
406+
Ready: isLogKnown(p.Verdict, p.Count, autoPromoteAfter),
392407
}
393-
if autoPromoteAfter > 0 {
394-
r.Needed = autoPromoteAfter
395-
} // else Needed=0 → indeterminate (manual-only promotion)
396408
if !r.Ready && pollInterval > 0 && p.BaselineFrequency > 0 {
397409
// BaselineFrequency is now a per-second sighting rate, so sightings/min
398410
// is just rate × 60 — poll-interval-independent. pollInterval is kept as

0 commit comments

Comments
 (0)