Fix metric timeseries gaps on nodes with many cores

On nodes with >300 cores, one interval's burst of per-hwthread metrics
overran the fixed 200-slot channels. With blocking sends at every hop,
sink back-pressure propagated to the collectors, the collection round
exceeded the interval, and time.Ticker silently dropped the missed
ticks - whole intervals were skipped without any log message.

- multiChanTicker: deliver ticks non-blockingly and warn when a
  consumer misses a tick instead of stalling all consumers; guard the
  channel list with a mutex (data race with AddChannel)
- collectorManager: run the collection round detached from the tick
  loop, skip-and-warn when a round is still running, log per-collector
  and per-round durations at debug level, close serial collectors on
  shutdown
- metricRouter: buffer the interval timestamp channel and drain it
  before stamping, so metrics never carry the previous interval's
  timestamp; warn when the collector input channel is full at tick time
- main: scale the inter-manager channels to max(200, 24*NumCPU),
  overridable with the new optional channel_buffer_size config option
- add first unit tests for ticker, collector manager and router

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 21:18:16 +02:00
parent b8e76e3bf0
commit 3d4c464166
12 changed files with 394 additions and 59 deletions
+4 -2
View File
@@ -30,8 +30,8 @@ Afterwards, you can add channels:
```golang
t := MultiChanTicker(duration)
c1 := make(chan time.Time)
c2 := make(chan time.Time)
c1 := make(chan time.Time, 1)
c2 := make(chan time.Time, 1)
t.AddChannel(c1)
t.AddChannel(c2)
@@ -46,3 +46,5 @@ for {
```
The result should be the same `time.Time` output in both channels, notified "simultaneously".
Ticks are delivered with a non-blocking send: a consumer that has not yet read the previous tick does not stall the ticker (which would silently drop `time.Ticker` fires for all consumers); instead, the tick for that consumer is skipped and a warning is logged. Register buffered channels (capacity 1) so a consumer that is briefly busy at tick time does not lose the tick.
+16 -4
View File
@@ -8,6 +8,8 @@
package multiChanTicker
import (
"fmt"
"sync"
"time"
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
@@ -15,6 +17,7 @@ import (
type multiChanTicker struct {
ticker *time.Ticker
mutex sync.Mutex // protects channels, which is appended to while the tick goroutine iterates it
channels []chan time.Time
done chan bool
}
@@ -40,21 +43,30 @@ func (t *multiChanTicker) Init(duration time.Duration) {
return
case ts := <-t.ticker.C:
cclog.ComponentDebug("MultiChanTicker", "Tick", ts)
for _, c := range t.channels {
t.mutex.Lock()
for i, c := range t.channels {
// Non-blocking send: a consumer that has not yet read the
// previous tick must not stall the ticker, otherwise
// time.Ticker silently drops fires for ALL consumers
select {
case <-t.done:
done()
return
case c <- ts:
default:
cclog.ComponentWarn("MultiChanTicker", fmt.Sprintf("consumer %d did not read previous tick, dropping tick %v", i, ts))
}
}
t.mutex.Unlock()
}
}
}()
}
func (t *multiChanTicker) AddChannel(channel chan time.Time) {
if cap(channel) == 0 {
cclog.ComponentWarn("MultiChanTicker", "unbuffered channel registered, ticks may be dropped if the consumer is not ready")
}
t.mutex.Lock()
t.channels = append(t.channels, channel)
t.mutex.Unlock()
}
func (t *multiChanTicker) Close() {
@@ -0,0 +1,51 @@
package multiChanTicker
import (
"testing"
"time"
)
// A consumer that never reads its channel must not stall the ticker
// or starve the other consumers
func TestStalledConsumerDoesNotStarveOthers(t *testing.T) {
stalled := make(chan time.Time, 1) // never read
fast := make(chan time.Time, 1)
ticker := NewTicker(10 * time.Millisecond)
defer ticker.Close()
ticker.AddChannel(stalled)
ticker.AddChannel(fast)
received := 0
deadline := time.After(5 * time.Second)
for received < 5 {
select {
case <-fast:
received++
case <-deadline:
t.Fatalf("received only %d ticks while another consumer stalled", received)
}
}
}
// Close() must return promptly even if no consumer reads its channel
func TestCloseWithStalledConsumer(t *testing.T) {
stalled := make(chan time.Time, 1) // never read
ticker := NewTicker(10 * time.Millisecond)
ticker.AddChannel(stalled)
// Let some ticks fire and be dropped
time.Sleep(50 * time.Millisecond)
closed := make(chan struct{})
go func() {
ticker.Close()
close(closed)
}()
select {
case <-closed:
case <-time.After(5 * time.Second):
t.Fatal("Close() blocked with a stalled consumer")
}
}