🐛 keep the stream and metrics tickers on their own channels (#399)

Traefik runs the plugin under yaegi, which shares the case list of a
select statement between every goroutine that runs it. Both ticker
loops ran the same select, so when the tickers fired together one loop
could end up waiting on the other's channel, dropping stream ticks for
up to two metrics intervals.

Range over the ticker channel instead, which yaegi evaluates per
execution. The stop channel was never used.

Fixes #377

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
mathieuHaandClaude Opus 5.5 authored and GitHub committed 2026-10-11 13:57:12 +02:00
1 parent bd94fb0c2b
commit efaa617e6d
2 files changed
+48 -16

No files matched your search

+11 -16
View File
@@ -70,8 +70,8 @@ var (
isCrowdsecStreamStartup = true
isCrowdsecStreamHealthy = true
updateFailure int64
streamTicker chan bool
metricsTicker chan bool
streamTicker *time.Ticker
metricsTicker *time.Ticker
lastMetricsPush time.Time
blockedRequests int64
)
@@ -324,7 +324,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
} else {
go handleStreamTicker(bouncer)
}
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
streamTicker = startTicker(config.UpdateIntervalSeconds, func() {
handleStreamTicker(bouncer)
})
}
@@ -333,7 +333,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
go handleMetricsTicker(bouncer)
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
metricsTicker = startTicker(config.MetricsUpdateIntervalSeconds, func() {
handleMetricsTicker(bouncer)
})
}
@@ -564,22 +564,17 @@ func handleMetricsTicker(bouncer *Bouncer) {
}
}
func startTicker(name string, updateInterval int64, log *slog.Logger, work func()) chan bool {
func startTicker(updateInterval int64, work func()) *time.Ticker {
ticker := time.NewTicker(time.Duration(updateInterval) * time.Second)
stop := make(chan bool, 1)
go func() {
defer log.Debug(name + "_ticker:stopped")
for {
select {
case <-ticker.C:
go runTicker(ticker.C, work)
return ticker
}
func runTicker(ticks <-chan time.Time, work func()) {
for range ticks {
go work()
case <-stop:
return
}
}
}()
return stop
}
// We are now in none or live mode.
func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
+37
View File
@@ -0,0 +1,37 @@
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func sendTicks(ticks chan time.Time, count int) {
for i := 0; i < count; i++ { //nolint:intrange
ticks <- time.Time{}
}
}
func Test_runTicker_keepsEachTickerOnItsOwnChannel(t *testing.T) {
const count = 20000
var streamRuns, metricsRuns int64
var runs sync.WaitGroup
runs.Add(2 * count)
streamTicks := make(chan time.Time)
metricsTicks := make(chan time.Time)
go runTicker(streamTicks, func() {
atomic.AddInt64(&streamRuns, 1)
runs.Done()
})
go runTicker(metricsTicks, func() {
atomic.AddInt64(&metricsRuns, 1)
runs.Done()
})
go sendTicks(streamTicks, count)
go sendTicks(metricsTicks, count)
runs.Wait()
if streamRuns != count || metricsRuns != count {
t.Errorf("runTicker ran stream %d and metrics %d times, want %d each", streamRuns, metricsRuns, count)
}
}