Skip to content

Commit 28f1eb0

Browse files
authored
feat: support rule-based user segments (#2723)
1 parent c946f12 commit 28f1eb0

62 files changed

Lines changed: 6306 additions & 3393 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api-description/apidocs.swagger.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8141,6 +8141,14 @@ definitions:
81418141
updatedAt:
81428142
type: string
81438143
format: int64
8144+
rules:
8145+
type: array
8146+
items:
8147+
type: object
8148+
$ref: '#/definitions/featureRule'
8149+
description: |-
8150+
The segment's rule-based membership definition. Delivered to server SDKs
8151+
alongside the user list so local evaluation can match users by attribute.
81448152
featureStrategy:
81458153
type: object
81468154
properties:

api-description/web-api.swagger.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13151,6 +13151,14 @@ definitions:
1315113151
type: string
1315213152
description:
1315313153
type: string
13154+
rules:
13155+
type: array
13156+
items:
13157+
type: object
13158+
$ref: '#/definitions/featureRule'
13159+
description: |-
13160+
Rule-based membership definition. A user belongs to the segment when it
13161+
is in the included-user list OR matches any of the rules.
1315413162
required:
1315513163
- name
1315613164
- environmentId
@@ -13642,6 +13650,18 @@ definitions:
1364213650
$ref: '#/definitions/bucketeerfeatureChangeType'
1364313651
rule:
1364413652
$ref: '#/definitions/featureRule'
13653+
featureRuleListValue:
13654+
type: object
13655+
properties:
13656+
values:
13657+
type: array
13658+
items:
13659+
type: object
13660+
$ref: '#/definitions/featureRule'
13661+
description: |-
13662+
RuleListValue is a wrapper for a repeated Rule field so that update
13663+
requests can distinguish "not set" (field is absent) from
13664+
"replace with an empty list" (field is present with no values).
1364513665
featureScheduledChangeCategory:
1364613666
type: string
1364713667
enum:
@@ -14072,6 +14092,11 @@ definitions:
1407214092
type: string
1407314093
description:
1407414094
type: string
14095+
rules:
14096+
$ref: '#/definitions/featureRuleListValue'
14097+
description: |-
14098+
Rule-based membership definition.
14099+
Absent = unchanged, present = full replacement of the rules list.
1407514100
required:
1407614101
- environmentId
1407714102
featureUpdateSegmentResponse:

evaluation/go/clause_evaluator.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/blang/semver"
2222

2323
featureproto "github.com/bucketeer-io/bucketeer/v2/proto/feature"
24+
userproto "github.com/bucketeer-io/bucketeer/v2/proto/user"
2425
)
2526

2627
type clauseEvaluator struct {
@@ -31,8 +32,9 @@ type clauseEvaluator struct {
3132
func (c *clauseEvaluator) Evaluate(
3233
targetValue string,
3334
clause *featureproto.Clause,
34-
userID string,
35+
user *userproto.User,
3536
segmentUsers []*featureproto.SegmentUser,
37+
segments map[string]*featureproto.Segment,
3638
flagVariations map[string]string,
3739
) (bool, error) {
3840
switch clause.Operator {
@@ -46,7 +48,7 @@ func (c *clauseEvaluator) Evaluate(
4648
case featureproto.Clause_ENDS_WITH:
4749
return c.endsWith(targetValue, clause.Values), nil
4850
case featureproto.Clause_SEGMENT:
49-
return c.segmentEvaluator.Evaluate(clause.Values, userID, segmentUsers), nil
51+
return c.segmentEvaluator.Evaluate(clause.Values, user, segments, segmentUsers)
5052
case featureproto.Clause_GREATER:
5153
return c.greater(targetValue, clause.Values), nil
5254
case featureproto.Clause_GREATER_OR_EQUAL:
@@ -60,6 +62,11 @@ func (c *clauseEvaluator) Evaluate(
6062
case featureproto.Clause_AFTER:
6163
return c.after(targetValue, clause.Values), nil
6264
case featureproto.Clause_FEATURE_FLAG:
65+
// flagVariations is nil when evaluating segment rules:
66+
// fail closed instead of returning an error.
67+
if flagVariations == nil {
68+
return false, nil
69+
}
6370
return c.dependencyEvaluator.Evaluate(clause.Attribute, clause.Values, flagVariations)
6471
case featureproto.Clause_PARTIALLY_MATCH:
6572
return c.partiallyMatches(targetValue, clause.Values), nil

evaluation/go/clause_evaluator_test.go

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/stretchr/testify/assert"
2222

2323
featureproto "github.com/bucketeer-io/bucketeer/v2/proto/feature"
24+
userproto "github.com/bucketeer-io/bucketeer/v2/proto/user"
2425
)
2526

2627
func TestGreaterFloat(t *testing.T) {
@@ -135,7 +136,7 @@ func TestGreaterFloat(t *testing.T) {
135136
Values: tc.values,
136137
}
137138
des := fmt.Sprintf("index: %d", i)
138-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
139+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
139140
assert.Equal(t, tc.expected, res, des)
140141
}
141142
}
@@ -236,7 +237,7 @@ func TestGreaterSemver(t *testing.T) {
236237
Values: tc.values,
237238
}
238239
des := fmt.Sprintf("index: %d", i)
239-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
240+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
240241
assert.Equal(t, tc.expected, res, des)
241242
}
242243
}
@@ -281,7 +282,7 @@ func TestGreaterString(t *testing.T) {
281282
Values: tc.values,
282283
}
283284
des := fmt.Sprintf("index: %d", i)
284-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
285+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
285286
assert.Equal(t, tc.expected, res, des)
286287
}
287288
}
@@ -428,7 +429,7 @@ func TestGreaterOrEqualFloat(t *testing.T) {
428429
Values: tc.values,
429430
}
430431
des := fmt.Sprintf("index: %d", i)
431-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
432+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
432433
assert.Equal(t, tc.expected, res, des)
433434
}
434435
}
@@ -558,7 +559,7 @@ func TestGreaterOrEqualSemver(t *testing.T) {
558559
Values: tc.values,
559560
}
560561
des := fmt.Sprintf("index: %d", i)
561-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
562+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
562563
assert.Equal(t, tc.expected, res, des)
563564
}
564565
}
@@ -618,7 +619,7 @@ func TestGreaterOrEqualString(t *testing.T) {
618619
Values: tc.values,
619620
}
620621
des := fmt.Sprintf("index: %d", i)
621-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
622+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
622623
assert.Equal(t, tc.expected, res, des)
623624
}
624625
}
@@ -718,7 +719,7 @@ func TestLessThanSemver(t *testing.T) {
718719
Values: tc.values,
719720
}
720721
des := fmt.Sprintf("index: %d", i)
721-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
722+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
722723
assert.Equal(t, tc.expected, res, des)
723724
}
724725
}
@@ -835,7 +836,7 @@ func TestLessFloat(t *testing.T) {
835836
Values: tc.values,
836837
}
837838
des := fmt.Sprintf("index: %d", i)
838-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
839+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
839840
assert.Equal(t, tc.expected, res, des)
840841
}
841842
}
@@ -885,7 +886,7 @@ func TestLessString(t *testing.T) {
885886
Values: tc.values,
886887
}
887888
des := fmt.Sprintf("index: %d", i)
888-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
889+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
889890
assert.Equal(t, tc.expected, res, des)
890891
}
891892
}
@@ -1027,7 +1028,7 @@ func TestLessOrEqualFloat(t *testing.T) {
10271028
Values: tc.values,
10281029
}
10291030
des := fmt.Sprintf("index: %d", i)
1030-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
1031+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
10311032
assert.Equal(t, tc.expected, res, des)
10321033
}
10331034
}
@@ -1157,7 +1158,7 @@ func TestLessThanOrEqualSemver(t *testing.T) {
11571158
Values: tc.values,
11581159
}
11591160
des := fmt.Sprintf("index: %d", i)
1160-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
1161+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
11611162
assert.Equal(t, tc.expected, res, des)
11621163
}
11631164
}
@@ -1217,7 +1218,7 @@ func TestLessOrEqualString(t *testing.T) {
12171218
Values: tc.values,
12181219
}
12191220
des := fmt.Sprintf("index: %d", i)
1220-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
1221+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
12211222
assert.Equal(t, tc.expected, res, des)
12221223
}
12231224
}
@@ -1276,7 +1277,7 @@ func TestBeforeInt(t *testing.T) {
12761277
Values: tc.values,
12771278
}
12781279
des := fmt.Sprintf("index: %d", i)
1279-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
1280+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
12801281
assert.Equal(t, tc.expected, res, des)
12811282
}
12821283
}
@@ -1335,7 +1336,7 @@ func TestAfterInt(t *testing.T) {
13351336
Values: tc.values,
13361337
}
13371338
des := fmt.Sprintf("index: %d", i)
1338-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
1339+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
13391340
assert.Equal(t, tc.expected, res, des)
13401341
}
13411342
}
@@ -1425,7 +1426,7 @@ func TestNotEquals(t *testing.T) {
14251426
Values: tc.values,
14261427
}
14271428
des := fmt.Sprintf("index: %d", i)
1428-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
1429+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
14291430
assert.Equal(t, tc.expected, res, des)
14301431
}
14311432
}
@@ -1613,7 +1614,7 @@ func TestSemverVPrefixNormalization(t *testing.T) {
16131614
Operator: tc.operator,
16141615
Values: tc.values,
16151616
}
1616-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
1617+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
16171618
assert.Equal(t, tc.expected, res)
16181619
})
16191620
}
@@ -1737,7 +1738,7 @@ func TestEqualsAndInSemverVPrefixNormalization(t *testing.T) {
17371738
Operator: tc.operator,
17381739
Values: tc.values,
17391740
}
1740-
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, "userId", nil, nil)
1741+
res, _ := clauseEvaluator.Evaluate(tc.targetValue, clause, &userproto.User{Id: "userId"}, nil, nil, nil)
17411742
assert.Equal(t, tc.expected, res)
17421743
})
17431744
}

evaluation/go/evaluation.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,26 +102,28 @@ func (e *evaluator) EvaluateFeatures(
102102
fs []*ftproto.Feature,
103103
user *userproto.User,
104104
mapSegmentUsers map[string][]*ftproto.SegmentUser,
105+
mapSegments map[string]*ftproto.Segment,
105106
targetTag string,
106107
) (*ftproto.UserEvaluations, error) {
107-
return e.evaluate(fs, user, mapSegmentUsers, false, targetTag)
108+
return e.evaluate(fs, user, mapSegmentUsers, mapSegments, false, targetTag)
108109
}
109110

110111
func (e *evaluator) EvaluateFeaturesByEvaluatedAt(
111112
fs []*ftproto.Feature,
112113
user *userproto.User,
113114
mapSegmentUsers map[string][]*ftproto.SegmentUser,
115+
mapSegments map[string]*ftproto.Segment,
114116
prevUEID string,
115117
evaluatedAt int64,
116118
userAttributesUpdated bool,
117119
targetTag string,
118120
) (*ftproto.UserEvaluations, error) {
119121
if prevUEID == "" {
120-
return e.evaluate(fs, user, mapSegmentUsers, true, targetTag)
122+
return e.evaluate(fs, user, mapSegmentUsers, mapSegments, true, targetTag)
121123
}
122124
now := time.Now()
123125
if evaluatedAt < now.Unix()-secondsToReEvaluateAll {
124-
return e.evaluate(fs, user, mapSegmentUsers, true, targetTag)
126+
return e.evaluate(fs, user, mapSegmentUsers, mapSegments, true, targetTag)
125127
}
126128
adjustedEvalAt := evaluatedAt - e.secondsForAdjustment
127129
updatedFeatures := make([]*ftproto.Feature, 0, len(fs))
@@ -137,19 +139,20 @@ func (e *evaluator) EvaluateFeaturesByEvaluatedAt(
137139
// If the UserEvaluationsID has changed, but both User Attributes and Feature Flags have not been updated,
138140
// it is considered unusual and a force update should be performed.
139141
if len(updatedFeatures) == 0 {
140-
return e.evaluate(fs, user, mapSegmentUsers, true, targetTag)
142+
return e.evaluate(fs, user, mapSegmentUsers, mapSegments, true, targetTag)
141143
}
142144
evalTargets, err := e.getEvalFeatures(updatedFeatures, fs)
143145
if err != nil {
144146
return nil, err
145147
}
146-
return e.evaluate(evalTargets, user, mapSegmentUsers, false, targetTag)
148+
return e.evaluate(evalTargets, user, mapSegmentUsers, mapSegments, false, targetTag)
147149
}
148150

149151
func (e *evaluator) evaluate(
150152
fs []*ftproto.Feature,
151153
user *userproto.User,
152154
mapSegmentUsers map[string][]*ftproto.SegmentUser,
155+
mapSegments map[string]*ftproto.Segment,
153156
forceUpdate bool,
154157
targetTag string,
155158
) (*ftproto.UserEvaluations, error) {
@@ -167,7 +170,7 @@ func (e *evaluator) evaluate(
167170
for _, id := range e.ListSegmentIDs(feature) {
168171
segmentUsers = append(segmentUsers, mapSegmentUsers[id]...)
169172
}
170-
reason, variation, err := e.assignUser(feature, user, segmentUsers, flagVariations)
173+
reason, variation, err := e.assignUser(feature, user, segmentUsers, mapSegments, flagVariations)
171174
if err != nil {
172175
return nil, err
173176
}
@@ -264,6 +267,7 @@ func (e *evaluator) assignUser(
264267
feature *ftproto.Feature,
265268
user *userproto.User,
266269
segmentUsers []*ftproto.SegmentUser,
270+
segments map[string]*ftproto.Segment,
267271
flagVariations map[string]string,
268272
) (*ftproto.Reason, *ftproto.Variation, error) {
269273
for _, pf := range feature.Prerequisites {
@@ -292,7 +296,7 @@ func (e *evaluator) assignUser(
292296
}
293297
}
294298
// evaluate ruleset
295-
rule, err := e.ruleEvaluator.Evaluate(feature.Rules, user, segmentUsers, flagVariations)
299+
rule, err := e.ruleEvaluator.Evaluate(feature.Rules, user, segmentUsers, segments, flagVariations)
296300
if err != nil {
297301
return nil, nil, err
298302
}

0 commit comments

Comments
 (0)