Skip to content

feat: multi-project support via selectors and flagSetId namespacing #1702

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Aug 13, 2025
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ curl -X POST -d '{"context":{}}' 'http://localhost:8016/ofrep/v1/evaluate/flags
grpcurl -import-path schemas/protobuf/flagd/evaluation/v1/ -proto evaluation.proto -plaintext -d '{}' localhost:8013 flagd.evaluation.v1.Service/ResolveAll | jq
```

#### Remote event streaming via gRPC

```sh
# notifies of flag changes (but does not evaluate)
grpcurl -import-path schemas/protobuf/flagd/evaluation/v1/ -proto evaluation.proto -plaintext -d '{}' localhost:8013 flagd.evaluation.v1.Service/EventStream
```

#### Flag configuration fetch via gRPC

```sh
Expand Down
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ test-flagd:
go test -race -covermode=atomic -cover -short ./flagd/pkg/... -coverprofile=flagd-coverage.out
test-flagd-proxy:
go test -race -covermode=atomic -cover -short ./flagd-proxy/pkg/... -coverprofile=flagd-proxy-coverage.out
flagd-integration-test: # dependent on ./bin/flagd start -f file:test-harness/flags/testing-flags.json -f file:test-harness/flags/custom-ops.json -f file:test-harness/flags/evaluator-refs.json -f file:test-harness/flags/zero-flags.json
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a duped recipe.

go test -cover ./test/integration $(ARGS)
flagd-benchmark-test:
go test -bench=Bench -short -benchtime=5s -benchmem ./core/... | tee benchmark.txt
flagd-integration-test-harness:
Expand All @@ -59,7 +57,9 @@ flagd-integration-test: # dependent on flagd-e2e-test-harness if not running in
run: # default to flagd
make run-flagd
run-flagd:
cd flagd; go run main.go start -f file:../config/samples/example_flags.flagd.json
cd flagd; go run main.go start -f file:../config/samples/example_flags.flagd.json
run-flagd-selector-demo:
cd flagd; go run main.go start -f file:../config/samples/example_flags.flagd.json -f file:../config/samples/example_flags.flagd.2.json
install:
cp systemd/flagd.service /etc/systemd/system/flagd.service
mkdir -p /etc/flagd
Expand Down
17 changes: 17 additions & 0 deletions config/samples/example_flags.flagd.2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "https://flagd.dev/schema/v0/flags.json",
"metadata": {
"flagSetId": "other",
"version": "v1"
},
"flags": {
"myStringFlag": {
"state": "ENABLED",
"variants": {
"dupe1": "dupe1",
"dupe2": "dupe2"
},
"defaultVariant": "dupe1"
}
}
}
22 changes: 18 additions & 4 deletions core/pkg/evaluator/fractional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
)

func TestFractionalEvaluation(t *testing.T) {
const source = "testSource"
var sources = []string{source}
ctx := context.Background()

commonFlags := Flags{
Expand Down Expand Up @@ -458,8 +460,13 @@ func TestFractionalEvaluation(t *testing.T) {
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
log := logger.NewLogger(nil, false)
je := NewJSON(log, store.NewFlags())
je.store.Update("", "", tt.flags.Flags, model.Metadata{})
s, err := store.NewStore(log, sources)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}

je := NewJSON(log, s)
je.store.Update(source, tt.flags.Flags, model.Metadata{})

value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant)

Expand All @@ -486,6 +493,8 @@ func TestFractionalEvaluation(t *testing.T) {
}

func BenchmarkFractionalEvaluation(b *testing.B) {
const source = "testSource"
var sources = []string{source}
ctx := context.Background()

flags := Flags{
Expand Down Expand Up @@ -587,8 +596,13 @@ func BenchmarkFractionalEvaluation(b *testing.B) {
for name, tt := range tests {
b.Run(name, func(b *testing.B) {
log := logger.NewLogger(nil, false)
je := NewJSON(log, store.NewFlags())
je.store.Update("", "", tt.flags.Flags, model.Metadata{})
s, err := store.NewStore(log, sources)
if err != nil {
b.Fatalf("NewStore failed: %v", err)
}
je := NewJSON(log, s)
je.store.Update(source, tt.flags.Flags, model.Metadata{})

for i := 0; i < b.N; i++ {
value, variant, reason, _, err := resolve[string](
ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant)
Expand Down
2 changes: 1 addition & 1 deletion core/pkg/evaluator/ievaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ IEvaluator is an extension of IResolver, allowing storage updates and retrievals
*/
type IEvaluator interface {
GetState() (string, error)
SetState(payload sync.DataSync) (model.Metadata, bool, error)
SetState(payload sync.DataSync) (map[string]interface{}, bool, error)
IResolver
}

Expand Down
33 changes: 22 additions & 11 deletions core/pkg/evaluator/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func (je *JSON) SetState(payload sync.DataSync) (map[string]interface{}, bool, e
var events map[string]interface{}
var reSync bool

events, reSync = je.store.Update(payload.Source, payload.Selector, definition.Flags, definition.Metadata)
events, reSync = je.store.Update(payload.Source, definition.Flags, definition.Metadata)

// Number of events correlates to the number of flags changed through this sync, record it
span.SetAttributes(attribute.Int("feature_flag.change_count", len(events)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated to this PR, but this looks like it could also be a metric

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya this is a good point. We should probably review all the metrics as part of pre-v1.

Expand Down Expand Up @@ -149,8 +149,14 @@ func (je *Resolver) ResolveAllValues(ctx context.Context, reqID string, context
_, span := je.tracer.Start(ctx, "resolveAll")
defer span.End()

var err error
allFlags, flagSetMetadata, err := je.store.GetAll(ctx)
var selector store.Selector
s := ctx.Value(store.SelectorContextKey{})
if s != nil {
selector = s.(store.Selector)
} else {
selector = store.NewSelector("")
}
allFlags, flagSetMetadata, err := je.store.GetAll(ctx, &selector)
if err != nil {
return nil, flagSetMetadata, fmt.Errorf("error retreiving flags from the store: %w", err)
}
Expand Down Expand Up @@ -301,19 +307,24 @@ func resolve[T constraints](ctx context.Context, reqID string, key string, conte
func (je *Resolver) evaluateVariant(ctx context.Context, reqID string, flagKey string, evalCtx map[string]any) (
variant string, variants map[string]interface{}, reason string, metadata map[string]interface{}, err error,
) {
flag, metadata, ok := je.store.Get(ctx, flagKey)
if !ok {

var selector store.Selector
s := ctx.Value(store.SelectorContextKey{})
if s != nil {
selector = s.(store.Selector)
} else {
selector = store.NewSelector("")
}
if s != nil {
selector = s.(store.Selector)
}
flag, metadata, err := je.store.Get(ctx, flagKey, &selector)
if err != nil {
// flag not found
je.Logger.DebugWithID(reqID, fmt.Sprintf("requested flag could not be found: %s", flagKey))
return "", map[string]interface{}{}, model.ErrorReason, metadata, errors.New(model.FlagNotFoundErrorCode)
}

// add selector to evaluation metadata
selector := je.store.SelectorForFlag(ctx, flag)
if selector != "" {
metadata[SelectorMetadataKey] = selector
}

for key, value := range flag.Metadata {
// If value is not nil or empty, copy to metadata
if value != nil {
Expand Down
Loading
Loading