Skip to content

Commit 7c501eb

Browse files
authored
feat: add intervalSeed source option (#1945)
* adds an `intervalSeed` source option * enables configuration of deterministic offset to polling * disabled by default (no offset, wall-clock polling is maintained * improves sync source logging consistency --- Fixes: #1944 The HTTP sync and blob syncs use robfig/cron with a simple interval-based cron expression (e.g. */5 * * * *). This means all flagd instances configured with the same polling interval will poll at exactly the same wall-clock times, which can cause thundering herd issues against the flag source server; this adds a feature to address this issue in large deployments. --------- Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
1 parent 330f7c1 commit 7c501eb

17 files changed

Lines changed: 499 additions & 206 deletions

File tree

core/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ require (
1515
github.com/open-feature/flagd-schemas v0.2.13
1616
github.com/open-feature/open-feature-operator/api v0.2.47
1717
github.com/prometheus/client_golang v1.23.2
18-
github.com/robfig/cron v1.2.0
18+
github.com/robfig/cron/v3 v3.0.1
1919
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
2020
github.com/stretchr/testify v1.11.1
2121
github.com/twmb/murmur3 v1.1.8

core/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,8 +279,8 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy
279279
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
280280
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
281281
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
282-
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
283-
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
282+
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
283+
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
284284
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
285285
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
286286
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=

core/pkg/sync/blob/blob_sync.go

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/open-feature/flagd/core/pkg/logger"
1212
"github.com/open-feature/flagd/core/pkg/sync"
13+
"github.com/open-feature/flagd/core/pkg/sync/internal/polling"
1314
"github.com/open-feature/flagd/core/pkg/utils"
1415
"gocloud.dev/blob"
1516
_ "gocloud.dev/blob/azureblob" // needed to initialize Azure Blob Storage driver
@@ -21,20 +22,13 @@ type Sync struct {
2122
Bucket string
2223
Object string
2324
BlobURLMux *blob.URLMux
24-
Cron Cron
25+
Poller polling.Poller
2526
Logger *logger.Logger
2627
Interval uint32
2728
ready bool
2829
lastUpdated time.Time
2930
}
3031

31-
// Cron defines the behaviour required of a cron
32-
type Cron interface {
33-
AddFunc(spec string, cmd func()) error
34-
Start()
35-
Stop()
36-
}
37-
3832
func (hs *Sync) Init(_ context.Context) error {
3933
if hs.Bucket == "" {
4034
return errors.New("no bucket string set")
@@ -50,24 +44,26 @@ func (hs *Sync) IsReady() bool {
5044
}
5145

5246
func (hs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error {
53-
hs.Logger.Info(fmt.Sprintf("starting sync from %s/%s with interval %ds", hs.Bucket, hs.Object, hs.Interval))
54-
_ = hs.Cron.AddFunc(fmt.Sprintf("*/%d * * * *", hs.Interval), func() {
55-
err := hs.sync(ctx, dataSync, false)
56-
if err != nil {
57-
hs.Logger.Warn(fmt.Sprintf("sync failed: %v", err))
58-
}
59-
})
47+
hs.Logger.Info(fmt.Sprintf("starting sync from %s/%s (interval: %ds)", hs.Bucket, hs.Object, hs.Interval))
48+
6049
// Initial fetch
61-
hs.Logger.Debug(fmt.Sprintf("initial sync of the %s/%s", hs.Bucket, hs.Object))
50+
hs.Logger.Debug(fmt.Sprintf("initial fetch from %s/%s", hs.Bucket, hs.Object))
6251
err := hs.sync(ctx, dataSync, false)
6352
if err != nil {
6453
return err
6554
}
6655

6756
hs.ready = true
68-
hs.Cron.Start()
69-
<-ctx.Done()
70-
hs.Cron.Stop()
57+
58+
hs.Logger.Debug(fmt.Sprintf("polling %s/%s every %ds (offset: %ds)",
59+
hs.Bucket, hs.Object, hs.Interval, hs.Poller.Offset()))
60+
61+
hs.Poller.Start(ctx, func() {
62+
err := hs.sync(ctx, dataSync, false)
63+
if err != nil {
64+
hs.Logger.Warn(fmt.Sprintf("sync failed: %v", err))
65+
}
66+
})
7167

7268
return nil
7369
}

core/pkg/sync/blob/blob_sync_test.go

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"github.com/open-feature/flagd/core/pkg/logger"
1010
"github.com/open-feature/flagd/core/pkg/sync"
1111
synctesting "github.com/open-feature/flagd/core/pkg/sync/testing"
12-
"go.uber.org/mock/gomock"
1312
)
1413

1514
func TestBlobSync(t *testing.T) {
@@ -38,17 +37,12 @@ func TestBlobSync(t *testing.T) {
3837

3938
for name, tt := range tests {
4039
t.Run(name, func(t *testing.T) {
41-
ctrl := gomock.NewController(t)
42-
mockCron := synctesting.NewMockCron(ctrl)
43-
mockCron.EXPECT().AddFunc(gomock.Any(), gomock.Any()).DoAndReturn(func(spec string, cmd func()) error {
44-
return nil
45-
})
46-
mockCron.EXPECT().Start().Times(1)
40+
mockPoller := synctesting.NewMockPoller()
4741

4842
blobSync := &Sync{
4943
Bucket: tt.scheme + "://" + tt.bucket,
5044
Object: tt.object,
51-
Cron: mockCron,
45+
Poller: mockPoller,
5246
Logger: logger.NewLogger(nil, false),
5347
}
5448
blobMock := NewMockBlob(tt.scheme, func() *Sync {
@@ -73,19 +67,19 @@ func TestBlobSync(t *testing.T) {
7367
if data.FlagData != tt.convertedContent {
7468
t.Errorf("expected content: %s, but received content: %s", tt.convertedContent, data.FlagData)
7569
}
76-
tickWithConfigChange(t, mockCron, dataSyncChan, blobMock, tt.object, tt.convertedContent)
77-
tickWithoutConfigChange(t, mockCron, dataSyncChan)
78-
tickWithConfigChange(t, mockCron, dataSyncChan, blobMock, tt.object, tt.convertedContent)
79-
tickWithoutConfigChange(t, mockCron, dataSyncChan)
80-
tickWithoutConfigChange(t, mockCron, dataSyncChan)
70+
tickWithConfigChange(t, mockPoller, dataSyncChan, blobMock, tt.object, tt.convertedContent)
71+
tickWithoutConfigChange(t, mockPoller, dataSyncChan)
72+
tickWithConfigChange(t, mockPoller, dataSyncChan, blobMock, tt.object, tt.convertedContent)
73+
tickWithoutConfigChange(t, mockPoller, dataSyncChan)
74+
tickWithoutConfigChange(t, mockPoller, dataSyncChan)
8175
})
8276
}
8377
}
8478

85-
func tickWithConfigChange(t *testing.T, mockCron *synctesting.MockCron, dataSyncChan chan sync.DataSync, blobMock *MockBlob, object string, newConfig string) {
79+
func tickWithConfigChange(t *testing.T, mockPoller *synctesting.MockPoller, dataSyncChan chan sync.DataSync, blobMock *MockBlob, object string, newConfig string) {
8680
time.Sleep(1 * time.Millisecond) // sleep so the new file has different modification date
8781
blobMock.AddObject(object, newConfig)
88-
mockCron.Tick()
82+
mockPoller.Tick()
8983
select {
9084
case data, ok := <-dataSyncChan:
9185
if ok {
@@ -100,8 +94,8 @@ func tickWithConfigChange(t *testing.T, mockCron *synctesting.MockCron, dataSync
10094
}
10195
}
10296

103-
func tickWithoutConfigChange(t *testing.T, mockCron *synctesting.MockCron, dataSyncChan chan sync.DataSync) {
104-
mockCron.Tick()
97+
func tickWithoutConfigChange(t *testing.T, mockPoller *synctesting.MockPoller, dataSyncChan chan sync.DataSync) {
98+
mockPoller.Tick()
10599
select {
106100
case data, ok := <-dataSyncChan:
107101
if ok {
@@ -119,13 +113,12 @@ func TestReSync(t *testing.T) {
119113
bucket = "b"
120114
object = "flags.json"
121115
)
122-
ctrl := gomock.NewController(t)
123-
mockCron := synctesting.NewMockCron(ctrl)
116+
mockPoller := synctesting.NewMockPoller()
124117

125118
blobSync := &Sync{
126119
Bucket: scheme + "://" + bucket,
127120
Object: object,
128-
Cron: mockCron,
121+
Poller: mockPoller,
129122
Logger: logger.NewLogger(nil, false),
130123
}
131124
blobMock := NewMockBlob(scheme, func() *Sync {

core/pkg/sync/builder/syncbuilder.go

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import (
1212
"github.com/open-feature/flagd/core/pkg/sync/grpc"
1313
"github.com/open-feature/flagd/core/pkg/sync/grpc/credentials"
1414
httpSync "github.com/open-feature/flagd/core/pkg/sync/http"
15+
"github.com/open-feature/flagd/core/pkg/sync/internal/polling"
1516
"github.com/open-feature/flagd/core/pkg/sync/kubernetes"
16-
"github.com/robfig/cron"
1717
"go.uber.org/zap"
1818
"gocloud.dev/blob"
1919
"k8s.io/client-go/dynamic"
@@ -110,19 +110,19 @@ func (sb *SyncBuilder) syncFromConfig(sourceConfig sync.SourceConfig, logger *lo
110110
return sb.newK8s(sourceConfig.URI, logger)
111111
case syncProviderHTTP:
112112
logger.Debug(fmt.Sprintf("using remote sync-provider for: %s", sourceConfig.URI))
113-
return httpSync.NewHTTP(sourceConfig, logger), nil
113+
return sb.newHTTP(sourceConfig, logger)
114114
case syncProviderGrpc:
115115
logger.Debug(fmt.Sprintf("using grpc sync-provider for: %s", sourceConfig.URI))
116116
return sb.newGRPC(sourceConfig, logger), nil
117117
case syncProviderGcs:
118118
logger.Debug(fmt.Sprintf("using blob sync-provider with gcs driver for: %s", sourceConfig.URI))
119-
return sb.newGcs(sourceConfig, logger), nil
119+
return sb.newGcs(sourceConfig, logger)
120120
case syncProviderAzblob:
121121
logger.Debug(fmt.Sprintf("using blob sync-provider with azblob driver for: %s", sourceConfig.URI))
122122
return sb.newAzblob(sourceConfig, logger)
123123
case syncProviderS3:
124124
logger.Debug(fmt.Sprintf("using blob sync-provider with s3 driver for: %s", sourceConfig.URI))
125-
return sb.newS3(sourceConfig, logger), nil
125+
return sb.newS3(sourceConfig, logger)
126126

127127
default:
128128
return nil, fmt.Errorf("invalid sync provider: %s, must be one of with "+
@@ -201,17 +201,38 @@ func (sb *SyncBuilder) newGRPC(config sync.SourceConfig, logger *logger.Logger)
201201
}
202202
}
203203

204-
func (sb *SyncBuilder) newGcs(config sync.SourceConfig, logger *logger.Logger) *blobSync.Sync {
204+
// defaultInterval returns the configured interval or 5 seconds if unset,
205+
// along with a validated poller.
206+
func newPoller(config sync.SourceConfig) (uint32, *polling.CronPoller, error) {
207+
var interval uint32 = 5
208+
if config.Interval != 0 {
209+
interval = config.Interval
210+
}
211+
poller, err := polling.NewCronPoller(interval, config.IntervalSeed)
212+
if err != nil {
213+
return 0, nil, err
214+
}
215+
return interval, poller, nil
216+
}
217+
218+
func (sb *SyncBuilder) newHTTP(config sync.SourceConfig, logger *logger.Logger) (*httpSync.Sync, error) {
219+
interval, poller, err := newPoller(config)
220+
if err != nil {
221+
return nil, fmt.Errorf("invalid http sync configuration: %w", err)
222+
}
223+
return httpSync.NewHTTP(config, logger, poller, interval), nil
224+
}
225+
226+
func (sb *SyncBuilder) newGcs(config sync.SourceConfig, logger *logger.Logger) (*blobSync.Sync, error) {
205227
// Extract bucket uri and object name from the full URI:
206228
// gs://bucket/path/to/object results in gs://bucket/ as bucketUri and
207229
// path/to/object as an object name.
208230
bucketURI := regGcs.FindString(config.URI)
209231
objectName := regGcs.ReplaceAllString(config.URI, "")
210232

211-
// Defaults to 5 seconds if interval is not set.
212-
var interval uint32 = 5
213-
if config.Interval != 0 {
214-
interval = config.Interval
233+
interval, poller, err := newPoller(config)
234+
if err != nil {
235+
return nil, fmt.Errorf("invalid gcs sync configuration: %w", err)
215236
}
216237

217238
return &blobSync.Sync{
@@ -225,8 +246,8 @@ func (sb *SyncBuilder) newGcs(config sync.SourceConfig, logger *logger.Logger) *
225246
zap.String("sync", "gcs"),
226247
),
227248
Interval: interval,
228-
Cron: cron.New(),
229-
}
249+
Poller: poller,
250+
}, nil
230251
}
231252

232253
func (sb *SyncBuilder) newAzblob(config sync.SourceConfig, logger *logger.Logger) (*blobSync.Sync, error) {
@@ -245,10 +266,9 @@ func (sb *SyncBuilder) newAzblob(config sync.SourceConfig, logger *logger.Logger
245266
bucketURI := regAzblob.FindString(config.URI)
246267
objectName := regAzblob.ReplaceAllString(config.URI, "")
247268

248-
// Defaults to 5 seconds if interval is not set.
249-
var interval uint32 = 5
250-
if config.Interval != 0 {
251-
interval = config.Interval
269+
interval, poller, err := newPoller(config)
270+
if err != nil {
271+
return nil, fmt.Errorf("invalid azblob sync configuration: %w", err)
252272
}
253273

254274
return &blobSync.Sync{
@@ -262,21 +282,20 @@ func (sb *SyncBuilder) newAzblob(config sync.SourceConfig, logger *logger.Logger
262282
zap.String("sync", "azblob"),
263283
),
264284
Interval: interval,
265-
Cron: cron.New(),
285+
Poller: poller,
266286
}, nil
267287
}
268288

269-
func (sb *SyncBuilder) newS3(config sync.SourceConfig, logger *logger.Logger) *blobSync.Sync {
289+
func (sb *SyncBuilder) newS3(config sync.SourceConfig, logger *logger.Logger) (*blobSync.Sync, error) {
270290
// Extract bucket uri and object name from the full URI:
271291
// s3://bucket/path/to/object results in s3://bucket/ as bucketUri and
272292
// path/to/object as an object name.
273293
bucketURI := regS3.FindString(config.URI)
274294
objectName := regS3.ReplaceAllString(config.URI, "")
275295

276-
// Defaults to 5 seconds if interval is not set.
277-
var interval uint32 = 5
278-
if config.Interval != 0 {
279-
interval = config.Interval
296+
interval, poller, err := newPoller(config)
297+
if err != nil {
298+
return nil, fmt.Errorf("invalid s3 sync configuration: %w", err)
280299
}
281300

282301
return &blobSync.Sync{
@@ -290,8 +309,8 @@ func (sb *SyncBuilder) newS3(config sync.SourceConfig, logger *logger.Logger) *b
290309
zap.String("sync", "s3"),
291310
),
292311
Interval: interval,
293-
Cron: cron.New(),
294-
}
312+
Poller: poller,
313+
}, nil
295314
}
296315

297316
type IK8sClientBuilder interface {

core/pkg/sync/builder/syncbuilder_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,10 +338,11 @@ func Test_GcsConfig(t *testing.T) {
338338
}
339339
for _, tt := range tests {
340340
t.Run(tt.name, func(t *testing.T) {
341-
gcsSync := NewSyncBuilder().newGcs(sync.SourceConfig{
341+
gcsSync, err := NewSyncBuilder().newGcs(sync.SourceConfig{
342342
URI: tt.uri,
343343
Interval: tt.interval,
344344
}, lg)
345+
require.NoError(t, err)
345346
require.Equal(t, tt.expectedBucket, gcsSync.Bucket)
346347
require.Equal(t, tt.expectedObject, gcsSync.Object)
347348
require.Equal(t, int(tt.expectedInterval), int(gcsSync.Interval))
@@ -479,10 +480,11 @@ func Test_S3Config(t *testing.T) {
479480
}
480481
for _, tt := range tests {
481482
t.Run(tt.name, func(t *testing.T) {
482-
s3Sync := NewSyncBuilder().newS3(sync.SourceConfig{
483+
s3Sync, err := NewSyncBuilder().newS3(sync.SourceConfig{
483484
URI: tt.uri,
484485
Interval: tt.interval,
485486
}, lg)
487+
require.NoError(t, err)
486488
require.Equal(t, tt.expectedBucket, s3Sync.Bucket)
487489
require.Equal(t, tt.expectedObject, s3Sync.Object)
488490
require.Equal(t, int(tt.expectedInterval), int(s3Sync.Interval))

core/pkg/sync/builder/utils_test.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ func TestParseSource(t *testing.T) {
4040
Provider: syncProviderFile,
4141
},
4242
{
43-
URI: "http://test.com",
44-
Provider: syncProviderHTTP,
43+
URI: "http://test.com",
44+
Provider: syncProviderHTTP,
4545
AuthHeader: "Bearer :)",
4646
},
4747
{
@@ -71,7 +71,7 @@ func TestParseSource(t *testing.T) {
7171
{"uri":"config/samples/example_flags.json","provider":"file"},
7272
{"uri":"https://secure-remote","provider":"http","authHeader":"Bearer bearer-dji34ld2l"},
7373
{"uri":"https://secure-remote","provider":"http","authHeader":"Basic dXNlcjpwYXNz"},
74-
{"uri":"http://site.com","provider":"http","interval":77 },
74+
{"uri":"http://site.com","provider":"http","interval":77,"intervalSeed":"my-pod-123"},
7575
{"uri":"default/my-flag-config","provider":"kubernetes"},
7676
{"uri":"grpc-source:8080","provider":"grpc"},
7777
{"uri":"my-flag-source:8080","provider":"grpc", "tls":true, "certPath": "/certs/ca.cert", "providerID": "flagd-weatherapp-sidecar", "selector": "source=database,app=weatherapp"}
@@ -93,9 +93,10 @@ func TestParseSource(t *testing.T) {
9393
AuthHeader: "Basic dXNlcjpwYXNz",
9494
},
9595
{
96-
URI: "http://site.com",
97-
Provider: syncProviderHTTP,
98-
Interval: 77,
96+
URI: "http://site.com",
97+
Provider: syncProviderHTTP,
98+
Interval: 77,
99+
IntervalSeed: "my-pod-123",
99100
},
100101
{
101102
URI: "default/my-flag-config",

core/pkg/sync/file/filepath_sync.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,11 @@ func (fs *Sync) setReady(val bool) {
9494
//nolint:funlen
9595
func (fs *Sync) Sync(ctx context.Context, dataSync chan<- sync.DataSync) error {
9696
defer fs.watcher.Close()
97+
fs.Logger.Info(fmt.Sprintf("starting sync from %s", fs.URI))
98+
fs.Logger.Debug(fmt.Sprintf("initial fetch from %s", fs.URI))
9799
fs.sendDataSync(ctx, dataSync)
98100
fs.setReady(true)
99-
fs.Logger.Info(fmt.Sprintf("watching filepath: %s", fs.URI))
101+
fs.Logger.Debug(fmt.Sprintf("watching %s for changes", fs.URI))
100102
for {
101103
select {
102104
case event, ok := <-fs.watcher.Events():

0 commit comments

Comments
 (0)