Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,32 @@ engine.AddRule(rule2) // ❌ Error: weight conflict
- Ensuring consistent rule priority ordering
- Preventing accidental duplicate rule weights

### Intelligent Conflict Detection

The system uses efficient forest-based conflict detection that only checks for weight conflicts between rules that can actually intersect:

```go
// These rules DON'T intersect - same weight allowed
rule1 := matcher.NewRule("rule1").
Dimension("product", "ProductA", matcher.MatchTypeEqual).
ManualWeight(10.0).Build()

rule2 := matcher.NewRule("rule2").
Dimension("product", "ProductB", matcher.MatchTypeEqual). // Different product
ManualWeight(10.0).Build() // ✅ Same weight OK - no intersection

// These rules DO intersect - same weight blocked
rule3 := matcher.NewRule("rule3").
Dimension("product", "Product", matcher.MatchTypePrefix). // Prefix "Product"
ManualWeight(15.0).Build()

rule4 := matcher.NewRule("rule4").
Dimension("product", "ProductX", matcher.MatchTypeEqual). // "ProductX" starts with "Product"
ManualWeight(15.0).Build() // ❌ Weight conflict - rules intersect
```

**Performance**: Uses O(log n) forest traversal instead of O(n²) rule-pair checking for optimal efficiency.

## 🏗️ Architecture

```text
Expand Down Expand Up @@ -378,6 +404,51 @@ result, err := engine.FindBestMatch(partialQuery)

**Important**: Partial queries only traverse `MatchTypeAny` branches for unspecified dimensions. If you want rules to be found by partial queries, store the optional dimensions with `MatchTypeAny`.

### Rule Exclusion

The engine supports excluding specific rules from query results, useful for A/B testing, rule versioning, or temporarily disabling rules:

```go
// Create multiple rules
rule1 := matcher.NewRule("rule1").
Product("ProductA", matcher.MatchTypeEqual, 10.0).
Route("main", matcher.MatchTypeEqual, 5.0).
ManualWeight(15.0).
Build()

rule2 := matcher.NewRule("rule2").
Product("ProductA", matcher.MatchTypeEqual, 10.0).
Route("main", matcher.MatchTypeEqual, 5.0).
ManualWeight(10.0).
Build()

engine.AddRule(rule1)
engine.AddRule(rule2)

// Regular query - finds highest weight rule
query := matcher.CreateQuery(map[string]string{
"product": "ProductA",
"route": "main",
})
result, _ := engine.FindBestMatch(query) // Returns rule1 (weight: 15.0)

// Query excluding specific rules
excludeQuery := matcher.CreateQueryWithExcludedRules(map[string]string{
"product": "ProductA",
"route": "main",
}, []string{"rule1"})
result, _ = engine.FindBestMatch(excludeQuery) // Returns rule2 (weight: 10.0)

// Works with FindAllMatches too
allMatches, _ := engine.FindAllMatches(excludeQuery) // Returns only rule2
```

#### Use Cases
- **A/B Testing**: Exclude certain rule variants from specific user segments
- **Rule Versioning**: Temporarily exclude old rule versions during migration
- **Debugging**: Isolate specific rules during troubleshooting
- **Feature Flags**: Dynamically enable/disable rules without deletion

### Custom Dimensions

```go
Expand Down
83 changes: 57 additions & 26 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,32 +194,7 @@ func (me *MatcherEngine) GetRule(ruleID string) (*Rule, error) {
}

// Return a copy to prevent external modification
ruleCopy := &Rule{
ID: rule.ID,
TenantID: rule.TenantID,
ApplicationID: rule.ApplicationID,
Dimensions: make([]*DimensionValue, len(rule.Dimensions)),
Metadata: make(map[string]string),
Status: rule.Status,
CreatedAt: rule.CreatedAt,
UpdatedAt: rule.UpdatedAt,
}

// Deep copy dimensions
for i, dim := range rule.Dimensions {
ruleCopy.Dimensions[i] = &DimensionValue{
DimensionName: dim.DimensionName,
Value: dim.Value,
MatchType: dim.MatchType,
}
}

// Copy metadata
for k, v := range rule.Metadata {
ruleCopy.Metadata[k] = v
}

return ruleCopy, nil
return rule.Clone(), nil
}

// DeleteRule removes a rule by ID
Expand Down Expand Up @@ -428,6 +403,62 @@ func CreateQueryWithAllRulesTenantAndDynamicConfigs(tenantID, applicationID stri
}
}

// CreateQueryWithExcludedRules creates a query that excludes specific rules by ID
func CreateQueryWithExcludedRules(values map[string]string, excludeRuleIDs []string) *QueryRule {
excludeMap := make(map[string]bool)
for _, ruleID := range excludeRuleIDs {
excludeMap[ruleID] = true
}
return &QueryRule{
Values: values,
IncludeAllRules: false, // Default to working rules only
ExcludeRules: excludeMap,
}
}

// CreateQueryWithAllRulesAndExcluded creates a query that includes all rules but excludes specific ones
func CreateQueryWithAllRulesAndExcluded(values map[string]string, excludeRuleIDs []string) *QueryRule {
excludeMap := make(map[string]bool)
for _, ruleID := range excludeRuleIDs {
excludeMap[ruleID] = true
}
return &QueryRule{
Values: values,
IncludeAllRules: true,
ExcludeRules: excludeMap,
}
}

// CreateQueryWithTenantAndExcluded creates a tenant-scoped query with excluded rules
func CreateQueryWithTenantAndExcluded(tenantID, applicationID string, values map[string]string, excludeRuleIDs []string) *QueryRule {
excludeMap := make(map[string]bool)
for _, ruleID := range excludeRuleIDs {
excludeMap[ruleID] = true
}
return &QueryRule{
TenantID: tenantID,
ApplicationID: applicationID,
Values: values,
IncludeAllRules: false, // Default to working rules only
ExcludeRules: excludeMap,
}
}

// CreateQueryWithAllRulesTenantAndExcluded creates a comprehensive query with tenant scope, all rules, and exclusions
func CreateQueryWithAllRulesTenantAndExcluded(tenantID, applicationID string, values map[string]string, excludeRuleIDs []string) *QueryRule {
excludeMap := make(map[string]bool)
for _, ruleID := range excludeRuleIDs {
excludeMap[ruleID] = true
}
return &QueryRule{
TenantID: tenantID,
ApplicationID: applicationID,
Values: values,
IncludeAllRules: true,
ExcludeRules: excludeMap,
}
}

// GetForestStats returns detailed forest index statistics
func (me *MatcherEngine) GetForestStats() map[string]interface{} {
me.matcher.mu.RLock()
Expand Down
14 changes: 7 additions & 7 deletions api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,12 @@ func TestAPIUpdateRule(t *testing.T) {
Dimension("region", "us-west", MatchTypeEqual).
Build()

// This should not fail - updateRule handles non-existent rules gracefully
if err := engine.UpdateRule(nonExistentRule); err != nil {
t.Errorf("UpdateRule should handle non-existent rules gracefully: %v", err)
// This should fail - updateRule should only update existing rules
if err := engine.UpdateRule(nonExistentRule); err == nil {
t.Error("UpdateRule should return an error for non-existent rules")
}

// Verify the non-existent rule was added
// Verify the non-existent rule was not added
nonExistentQuery := &QueryRule{
Values: map[string]string{
"region": "us-west",
Expand All @@ -148,7 +148,7 @@ func TestAPIUpdateRule(t *testing.T) {
t.Fatalf("FindAllMatches failed for non-existent rule query: %v", err)
}

// Should find the newly added rule
// Should not find the rule since it was not created
found := false
for _, match := range matches {
if match.Rule.ID == "non-existent" {
Expand All @@ -157,8 +157,8 @@ func TestAPIUpdateRule(t *testing.T) {
}
}

if !found {
t.Error("Expected to find the non-existent rule after update")
if found {
t.Error("Non-existent rule should not have been created by UpdateRule")
}
}

Expand Down
11 changes: 11 additions & 0 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"crypto/md5"
"fmt"
"sort"
"strings"
"sync"
"time"
)
Expand Down Expand Up @@ -64,6 +65,16 @@ func (qc *QueryCache) generateCacheKey(query *QueryRule) string {
keyString += "|include_all:false"
}

// Include excluded rules in the cache key
if len(query.ExcludeRules) > 0 {
var excludedRuleIDs []string
for ruleID := range query.ExcludeRules {
excludedRuleIDs = append(excludedRuleIDs, ruleID)
}
sort.Strings(excludedRuleIDs) // Sort for consistent key generation
keyString += "|exclude:" + strings.Join(excludedRuleIDs, ",")
}

// Include tenant and application context to ensure isolation
keyString += fmt.Sprintf("|tenant:%s|app:%s", query.TenantID, query.ApplicationID)

Expand Down
10 changes: 5 additions & 5 deletions dynamic_configs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,13 +318,13 @@ func TestDynamicConfigsWithMultipleMatchTypes(t *testing.T) {

// Create dimension config with different weights for different match types
priorityConfig := NewDimensionConfig("priority", 0, true) // default weight 1.0
priorityConfig.SetWeight(MatchTypeEqual, 10.0) // exact matches get 10.0
priorityConfig.SetWeight(MatchTypePrefix, 5.0) // prefix matches get 5.0
priorityConfig.SetWeight(MatchTypeAny, 2.0) // any matches get 2.0
priorityConfig.SetWeight(MatchTypeEqual, 10.0) // exact matches get 10.0
priorityConfig.SetWeight(MatchTypePrefix, 5.0) // prefix matches get 5.0
priorityConfig.SetWeight(MatchTypeAny, 2.0) // any matches get 2.0

categoryConfig := NewDimensionConfig("category", 1, true) // default weight 1.0
categoryConfig.SetWeight(MatchTypeEqual, 8.0) // exact matches get 8.0
categoryConfig.SetWeight(MatchTypeSuffix, 3.0) // suffix matches get 3.0
categoryConfig.SetWeight(MatchTypeEqual, 8.0) // exact matches get 8.0
categoryConfig.SetWeight(MatchTypeSuffix, 3.0) // suffix matches get 3.0

err = engine.AddDimension(priorityConfig)
if err != nil {
Expand Down
13 changes: 13 additions & 0 deletions example/exclude_rules_demo/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module exclude_rules_demo

go 1.21

replace github.com/Fabricates/Matcher => ../..

require github.com/Fabricates/Matcher v0.0.0-00010101000000-000000000000

require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/redis/go-redis/v9 v9.5.1 // indirect
)
10 changes: 10 additions & 0 deletions example/exclude_rules_demo/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
Loading