Skip to content

Commit a0d8f1b

Browse files
committed
feat(datalayer): allow per-source scrape intervals
Polling sources shared one Collector tick (~50ms), so slow-changing sources (DCGM, /v1/models) were scraped far more often than useful. Schedule each dataLayer.sources entry by a tick multiple of --refresh-metrics-interval; omit keeps every-tick behavior. Signed-off-by: noalimoy <nlimoy@redhat.com>
1 parent f1c4d89 commit a0d8f1b

15 files changed

Lines changed: 290 additions & 42 deletions

File tree

apix/config/v1alpha1/endpointpickerconfig_types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,13 @@ type DataLayerSource struct {
278278
// this Source. The entries are references to the names of entries of the Plugins
279279
// defined in the configuration's Plugins section
280280
Extractors []DataLayerExtractor `json:"extractors"`
281+
282+
// +optional
283+
// Interval is the scrape period for this polling source. It must be a
284+
// positive multiple of --refresh-metrics-interval (default 50ms). When
285+
// omitted, the source runs on every base tick. Ignored for
286+
// notification/endpoint sources.
287+
Interval *metav1.Duration `json:"interval,omitempty"`
281288
}
282289

283290
func (dls DataLayerSource) String() string {
@@ -286,6 +293,9 @@ func (dls DataLayerSource) String() string {
286293
if len(dls.Extractors) > 0 {
287294
parts = append(parts, fmt.Sprintf("Extractors: %v", dls.Extractors))
288295
}
296+
if dls.Interval != nil {
297+
parts = append(parts, "Interval: "+dls.Interval.Duration.String())
298+
}
289299
return "{" + strings.Join(parts, ", ") + "}"
290300
}
291301

apix/config/v1alpha1/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

deploy/config/sim-epp-gpu-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ plugins:
3737
dataLayer:
3838
sources:
3939
- pluginRef: dcgm-source
40+
interval: 1s
4041
extractors:
4142
- pluginRef: dcgm-extractor
4243
schedulingProfiles:

docs/architecture.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,12 @@ The data layer follows a Source -> Extract -> Attribute lifecycle:
224224
- Extractors populate per-endpoint attributes in the shared datastore for scorers
225225
- Scoring can rely on numerical metrics or metadata (model ID, adapter tags)
226226

227+
Polling sources share one Collector goroutine per endpoint. The base tick is
228+
`--refresh-metrics-interval` (default 50ms). A polling entry under `dataLayer.sources`
229+
may set `interval` to a positive multiple of that base tick; when omitted, the source
230+
runs on every base tick. Example: `interval: 1s` with the default base tick scrapes
231+
that source once per second.
232+
227233
See the upstream [Data Layer](https://github.com/llm-d/llm-d/blob/main/docs/architecture/core/router/epp/datalayer.md) doc for the canonical model.
228234

229235
---

pkg/epp/config/loader/configloader.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,9 @@ func buildDataLayerConfig(rawDataConfig *configapi.DataLayerConfig, handle fwkpl
438438
Plugin: sourcePlugin,
439439
Extractors: []fwkplugin.Plugin{},
440440
}
441+
if source.Interval != nil {
442+
sourceConfig.Interval = source.Interval.Duration
443+
}
441444
for _, extractor := range source.Extractors {
442445
extractorPlugin := handle.Plugin(extractor.PluginRef)
443446
if extractorPlugin == nil {

pkg/epp/datalayer/collector.go

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,19 @@ func NewCollector() *Collector {
7676
}
7777

7878
// Start launches the collection goroutine.
79-
// Each PollingDispatcher owns its extractors; the Collector calls Dispatch per tick.
80-
func (c *Collector) Start(ctx context.Context, ticker Ticker, ep fwkdl.Endpoint, dispatchers []fwkdl.PollingDispatcher) error {
81-
if len(dispatchers) == 0 {
79+
// Each ScheduledDispatcher owns its extractors; the Collector invokes
80+
// Dispatch on a source only when its period elapses (measured in base ticks).
81+
func (c *Collector) Start(ctx context.Context, ticker Ticker, ep fwkdl.Endpoint, scheduled []ScheduledDispatcher) error {
82+
if len(scheduled) == 0 {
8283
return errors.New("cannot start collector with empty dispatchers")
8384
}
84-
for _, d := range dispatchers {
85-
if d == nil {
85+
for _, s := range scheduled {
86+
if s.Dispatcher == nil {
8687
return errors.New("cannot add nil dispatcher")
8788
}
89+
if s.PeriodTicks < 1 {
90+
return errors.New("periodTicks must be >= 1")
91+
}
8892
}
8993
if err := ctx.Err(); err != nil {
9094
return err
@@ -97,7 +101,7 @@ func (c *Collector) Start(ctx context.Context, ticker Ticker, ep fwkdl.Endpoint,
97101
}
98102
ctx, cancel := context.WithCancel(ctx)
99103
c.cancel = cancel
100-
go c.run(ctx, ticker, ep, dispatchers)
104+
go c.run(ctx, ticker, ep, scheduled)
101105
return nil
102106
}
103107

@@ -112,30 +116,40 @@ func (c *Collector) Stop() {
112116
}
113117
}
114118

115-
func (c *Collector) run(ctx context.Context, ticker Ticker, ep fwkdl.Endpoint, dispatchers []fwkdl.PollingDispatcher) {
119+
func (c *Collector) run(ctx context.Context, ticker Ticker, ep fwkdl.Endpoint, scheduled []ScheduledDispatcher) {
116120
defer func() {
117121
close(c.done)
118122
ticker.Stop()
119123
}()
120124
logger := log.FromContext(ctx).WithValues("endpoint", ep.GetMetadata().GetIPAddress())
121125

126+
// nextDue[i] is the tick index when scheduled[i] should next Dispatch.
127+
// Zero means fire on the first tick (tick == 0).
128+
nextDue := make([]int, len(scheduled))
129+
tick := 0
130+
122131
for {
123132
select {
124133
case <-ctx.Done():
125134
return
126135
case <-ticker.Channel():
127-
for _, disp := range dispatchers {
136+
for i, s := range scheduled {
128137
if ctx.Err() != nil {
129138
return
130139
}
140+
if tick != nextDue[i] {
141+
continue
142+
}
131143
dispCtx, cancel := context.WithTimeout(ctx, defaultCollectionTimeout)
132-
if err := disp.Dispatch(dispCtx, ep); err != nil {
133-
tn := disp.TypedName()
144+
if err := s.Dispatcher.Dispatch(dispCtx, ep); err != nil {
145+
tn := s.Dispatcher.TypedName()
134146
metrics.RecordDataLayerPollError(tn.Type)
135147
logger.V(logging.DEBUG).Info("dispatch failed", "source", tn, "err", err)
136148
}
137149
cancel()
150+
nextDue[i] = tick + s.PeriodTicks
138151
}
152+
tick++
139153
}
140154
}
141155
}

pkg/epp/datalayer/collector_test.go

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,18 @@ func defaultEndpoint() fwkdl.Endpoint {
4949

5050
var (
5151
endpoint = defaultEndpoint()
52-
sources = []fwkdl.PollingDispatcher{&datasourcemocks.MetricsDataSource{}}
52+
sources = scheduleEveryTick([]fwkdl.PollingDispatcher{&datasourcemocks.MetricsDataSource{}})
5353
)
5454

55+
// scheduleEveryTick wraps each dispatcher with PeriodTicks=1 for collector tests.
56+
func scheduleEveryTick(dispatchers []fwkdl.PollingDispatcher) []ScheduledDispatcher {
57+
out := make([]ScheduledDispatcher, len(dispatchers))
58+
for i, d := range dispatchers {
59+
out[i] = ScheduledDispatcher{Dispatcher: d, PeriodTicks: 1}
60+
}
61+
return out
62+
}
63+
5564
// Mock PollingDispatchers for collector tests. Each tracks its own
5665
// invocation count and (for dataSource) runs bound extractors with metric
5766
// instrumentation. Same behavior the framework collector did before the
@@ -121,13 +130,14 @@ func TestCollectorStartInputs(t *testing.T) {
121130
tests := []struct {
122131
name string
123132
ctxCanceled bool
124-
sources []fwkdl.PollingDispatcher
133+
sources []ScheduledDispatcher
125134
wantErr bool
126135
wantErrIs error
127136
}{
128137
{name: "valid sources, live ctx", sources: sources},
129-
{name: "empty sources", sources: []fwkdl.PollingDispatcher{}, wantErr: true},
130-
{name: "nil source", sources: []fwkdl.PollingDispatcher{nil}, wantErr: true},
138+
{name: "empty sources", sources: []ScheduledDispatcher{}, wantErr: true},
139+
{name: "nil source", sources: []ScheduledDispatcher{{Dispatcher: nil, PeriodTicks: 1}}, wantErr: true},
140+
{name: "periodTicks zero", sources: []ScheduledDispatcher{{Dispatcher: &datasourcemocks.MetricsDataSource{}, PeriodTicks: 0}}, wantErr: true},
131141
{name: "cancelled parent ctx", ctxCanceled: true, sources: sources, wantErr: true, wantErrIs: context.Canceled},
132142
}
133143

@@ -182,7 +192,7 @@ func TestCollectorStop(t *testing.T) {
182192
setup: func(t *testing.T) *Collector {
183193
c := NewCollector()
184194
ticker := mocks.NewTicker()
185-
_ = c.Start(context.Background(), ticker, endpoint, []fwkdl.PollingDispatcher{})
195+
_ = c.Start(context.Background(), ticker, endpoint, []ScheduledDispatcher{})
186196
return c
187197
},
188198
},
@@ -213,7 +223,7 @@ func TestCollectorCollectsOnTicks(t *testing.T) {
213223
c := NewCollector()
214224
ticker := mocks.NewTicker()
215225

216-
require.NoError(t, c.Start(context.Background(), ticker, endpoint, []fwkdl.PollingDispatcher{source}))
226+
require.NoError(t, c.Start(context.Background(), ticker, endpoint, scheduleEveryTick([]fwkdl.PollingDispatcher{source})))
217227
defer c.Stop()
218228

219229
ticker.Tick()
@@ -224,6 +234,30 @@ func TestCollectorCollectsOnTicks(t *testing.T) {
224234
}, 1*time.Second, 2*time.Millisecond, "expected 2 collections")
225235
}
226236

237+
// TestCollectorRespectsPeriodTicks confirms slower sources skip ticks.
238+
func TestCollectorRespectsPeriodTicks(t *testing.T) {
239+
fast := &errSource{kind: "fast"}
240+
slow := &errSource{kind: "slow"}
241+
242+
c := NewCollector()
243+
ticker := mocks.NewTicker()
244+
require.NoError(t, c.Start(context.Background(), ticker, endpoint, []ScheduledDispatcher{
245+
{Dispatcher: fast, PeriodTicks: 1},
246+
{Dispatcher: slow, PeriodTicks: 4},
247+
}))
248+
defer c.Stop()
249+
250+
for i := 0; i < 8; i++ {
251+
ticker.Tick()
252+
}
253+
254+
require.Eventually(t, func() bool {
255+
return atomic.LoadInt64(&fast.CallCount) == 8 && atomic.LoadInt64(&slow.CallCount) == 2
256+
}, 1*time.Second, 2*time.Millisecond,
257+
"fast=%d slow=%d want fast=8 slow=2",
258+
atomic.LoadInt64(&fast.CallCount), atomic.LoadInt64(&slow.CallCount))
259+
}
260+
227261
// TestCollectorErrorMetrics confirms Poll/Extract errors increment per-event
228262
// counters (no transition dedup) and successes do not.
229263
func TestCollectorErrorMetrics(t *testing.T) {
@@ -285,7 +319,7 @@ func TestCollectorErrorMetrics(t *testing.T) {
285319

286320
c := NewCollector()
287321
ticker := mocks.NewTicker()
288-
require.NoError(t, c.Start(context.Background(), ticker, endpoint, []fwkdl.PollingDispatcher{src}))
322+
require.NoError(t, c.Start(context.Background(), ticker, endpoint, scheduleEveryTick([]fwkdl.PollingDispatcher{src})))
289323
defer c.Stop()
290324

291325
for i := 0; i < tt.ticks; i++ {
@@ -324,7 +358,7 @@ func TestCollectorRapidStartStopRaceFree(t *testing.T) {
324358
for i := 0; i < 100; i++ {
325359
c := NewCollector()
326360
ticker := mocks.NewTicker()
327-
require.NoError(t, c.Start(context.Background(), ticker, endpoint, []fwkdl.PollingDispatcher{src}))
361+
require.NoError(t, c.Start(context.Background(), ticker, endpoint, scheduleEveryTick([]fwkdl.PollingDispatcher{src})))
328362
ticker.Tick()
329363
c.Stop()
330364
}

pkg/epp/datalayer/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package datalayer
1818

1919
import (
2020
"fmt"
21+
"time"
2122

2223
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
2324
)
@@ -49,6 +50,9 @@ func (c *Config) String() string {
4950
type DataSourceConfig struct {
5051
Plugin plugin.Plugin // the source plugin instance (DataSource or PollingDispatcher)
5152
Extractors []plugin.Plugin // extractors defined for the data source
53+
// Interval is the scrape period for polling sources. Zero means every
54+
// Runtime base tick. Ignored for notification/endpoint sources.
55+
Interval time.Duration
5256
}
5357

5458
func (dsc DataSourceConfig) String() string {

pkg/epp/datalayer/manager.go

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -128,46 +128,66 @@ func (m *variantSourceMap[T]) findFirst(matches func(fwkplugin.Plugin) bool) sou
128128
return found
129129
}
130130

131-
// pollingDispatchers stores PollingDispatchers keyed by source name. Each
132-
// dispatcher owns its own extractors internally; the framework treats them
133-
// as opaque dispatch units.
131+
// pollingDispatchers stores scheduled PollingDispatchers keyed by source name.
132+
// Each dispatcher owns its own extractors; PeriodTicks is how often the
133+
// Collector should invoke it (in base ticks).
134134
type pollingDispatchers struct {
135135
mu sync.RWMutex
136-
m map[string]fwkdl.PollingDispatcher
136+
m map[string]ScheduledDispatcher
137137
}
138138

139139
func newPollingDispatchers() *pollingDispatchers {
140-
return &pollingDispatchers{m: make(map[string]fwkdl.PollingDispatcher)}
140+
return &pollingDispatchers{m: make(map[string]ScheduledDispatcher)}
141141
}
142142

143-
// Register installs disp under its TypedName.Name. Duplicate names fail loudly
143+
// Register installs s under its TypedName.Name. Duplicate names fail loudly
144144
// so a config error surfaces at startup instead of silently shadowing telemetry.
145-
func (p *pollingDispatchers) Register(disp fwkdl.PollingDispatcher) error {
145+
func (p *pollingDispatchers) Register(s ScheduledDispatcher) error {
146+
if s.Dispatcher == nil {
147+
return fmt.Errorf("cannot register nil %s dispatcher", variantPolling)
148+
}
149+
if s.PeriodTicks < 1 {
150+
return fmt.Errorf("periodTicks must be >= 1 for source %q", s.Dispatcher.TypedName().Name)
151+
}
146152
p.mu.Lock()
147153
defer p.mu.Unlock()
148-
name := disp.TypedName().Name
154+
name := s.Dispatcher.TypedName().Name
149155
if _, exists := p.m[name]; exists {
150156
return fmt.Errorf("duplicate %s source name %q", variantPolling, name)
151157
}
152-
p.m[name] = disp
158+
p.m[name] = s
153159
return nil
154160
}
155161

156162
// Get returns the dispatcher registered under name, if any.
157163
func (p *pollingDispatchers) Get(name string) (fwkdl.PollingDispatcher, bool) {
158164
p.mu.RLock()
159165
defer p.mu.RUnlock()
160-
d, ok := p.m[name]
161-
return d, ok
166+
s, ok := p.m[name]
167+
if !ok {
168+
return nil, false
169+
}
170+
return s.Dispatcher, true
162171
}
163172

164-
// Dispatchers returns a snapshot of all dispatchers.
173+
// Dispatchers returns a snapshot of all dispatchers (without schedule metadata).
165174
func (p *pollingDispatchers) Dispatchers() map[string]fwkdl.PollingDispatcher {
166175
p.mu.RLock()
167176
defer p.mu.RUnlock()
168177
out := make(map[string]fwkdl.PollingDispatcher, len(p.m))
169178
for k, v := range p.m {
170-
out[k] = v
179+
out[k] = v.Dispatcher
180+
}
181+
return out
182+
}
183+
184+
// Scheduled returns a snapshot of all scheduled dispatchers.
185+
func (p *pollingDispatchers) Scheduled() []ScheduledDispatcher {
186+
p.mu.RLock()
187+
defer p.mu.RUnlock()
188+
out := make([]ScheduledDispatcher, 0, len(p.m))
189+
for _, v := range p.m {
190+
out = append(out, v)
171191
}
172192
return out
173193
}

0 commit comments

Comments
 (0)