@@ -617,11 +617,11 @@ func (m *InMemoryMatcher) deleteDimension(dimensionName string) error {
617617// FindBestMatch finds the best matching rule for a query
618618func (m * InMemoryMatcher ) FindBestMatch (query * QueryRule ) (* MatchResult , error ) {
619619 start := time .Now ()
620-
620+
621621 // Update query count using atomic operation (no lock needed)
622622 atomic .AddInt64 (& m .stats .TotalQueries , 1 )
623623
624- // Check cache first
624+ // Check cache first while holding read lock
625625 if result := m .cache .Get (query ); result != nil {
626626 m .updateCacheStats (true )
627627 // Update average query time without lock
@@ -631,8 +631,15 @@ func (m *InMemoryMatcher) FindBestMatch(query *QueryRule) (*MatchResult, error)
631631
632632 m .updateCacheStats (false )
633633
634- // Find all matches
635- matches , err := m .FindAllMatches (query )
634+ // Acquire read lock for the entire lookup path so the cache check,
635+ // candidate computation and cache population happen with a consistent
636+ // view of the authoritative state. This prevents races where an
637+ // UpdateRule can interleave and make the query observe partial updates.
638+ m .mu .RLock ()
639+ defer m .mu .RUnlock ()
640+
641+ // Find all matches while holding the read lock using the nop-lock helper
642+ matches , err := m .findAllMatchesNoLock (query )
636643 if err != nil {
637644 // Update average query time without lock
638645 m .updateQueryTimeStats (time .Since (start ))
@@ -647,7 +654,7 @@ func (m *InMemoryMatcher) FindBestMatch(query *QueryRule) (*MatchResult, error)
647654
648655 best := matches [0 ] // already sorted
649656
650- // Cache the result
657+ // Cache the result (safe because we used read lock while computing matches)
651658 m .cache .Set (query , best )
652659
653660 // Update average query time without lock
@@ -662,13 +669,47 @@ func (m *InMemoryMatcher) updateQueryTimeStats(queryTime time.Duration) {
662669 // to avoid taking locks in the read path. This trades off some precision for
663670 // better concurrency performance and prevents read starvation.
664671 // The stats will be updated during rebuild, add/update/delete operations.
672+ atomic .AddInt64 (& m .stats .TotalQueryTime , queryTime .Milliseconds ())
665673}
666674
667675// FindAllMatches finds all matching rules for a query
668676func (m * InMemoryMatcher ) FindAllMatches (query * QueryRule ) ([]* MatchResult , error ) {
677+ // RLocked compatibility wrapper: acquire read lock and call helper
678+ m .mu .RLock ()
679+ defer m .mu .RUnlock ()
680+ return m .findAllMatchesNoLock (query )
681+ }
682+
683+ // FindAllMatchesInBatch finds the best matching rule for each query in the provided
684+ // slice and returns results in the same order. The entire operation is
685+ // performed while holding the matcher's read lock so the caller sees a
686+ // consistent snapshot with respect to concurrent updates.
687+ func (m * InMemoryMatcher ) FindAllMatchesInBatch (queries []* QueryRule ) ([][]* MatchResult , error ) {
669688 m .mu .RLock ()
670689 defer m .mu .RUnlock ()
671690
691+ results := make ([][]* MatchResult , len (queries ))
692+
693+ for i , q := range queries {
694+ matches , err := m .findAllMatchesNoLock (q )
695+ if err != nil {
696+ return nil , err
697+ }
698+
699+ if len (matches ) == 0 {
700+ results [i ] = nil
701+ continue
702+ }
703+
704+ results [i ] = matches
705+ }
706+
707+ return results , nil
708+ }
709+
710+ // findAllMatchesNoLock performs the match candidate verification without taking locks.
711+ // Caller must hold appropriate locks (RLock/RWMutex) if concurrency safety is required.
712+ func (m * InMemoryMatcher ) findAllMatchesNoLock (query * QueryRule ) ([]* MatchResult , error ) {
672713 // Get candidate rules from appropriate forest index
673714 forestIndex := m .getForestIndex (query .TenantID , query .ApplicationID )
674715 var candidates []RuleWithWeight
@@ -682,7 +723,7 @@ func (m *InMemoryMatcher) FindAllMatches(query *QueryRule) ([]*MatchResult, erro
682723
683724 var matches []* MatchResult
684725
685- // ATOMIC CONSISTENCY: Double-check approach to prevent race conditions
726+ // Double-check approach to prevent race conditions
686727 // For each candidate from forest, verify it actually matches the query dimensions
687728 // AND exists in m.rules AND its dimensions in m.rules still match the query
688729 for _ , candidate := range candidates {
@@ -720,6 +761,65 @@ func (m *InMemoryMatcher) FindAllMatches(query *QueryRule) ([]*MatchResult, erro
720761 return matches , nil
721762}
722763
764+ // FindBestMatchInBatch finds the best matching rule for each query in the provided
765+ // slice and returns results in the same order. The entire operation is
766+ // performed while holding the matcher's read lock so the caller sees a
767+ // consistent snapshot with respect to concurrent updates.
768+ func (m * InMemoryMatcher ) FindBestMatchInBatch (queries []* QueryRule ) ([]* MatchResult , error ) {
769+ start := time .Now ()
770+
771+ // Count these as queries (approximate) for stats
772+ atomic .AddInt64 (& m .stats .TotalQueries , int64 (len (queries )))
773+
774+ m .mu .RLock ()
775+ defer m .mu .RUnlock ()
776+
777+ results := make ([]* MatchResult , len (queries ))
778+
779+ // Collect items to cache after computing (we avoid mutating cache while
780+ // holding the read lock in a way that could conflict with other writers).
781+ type toCacheItem struct {
782+ q * QueryRule
783+ best * MatchResult
784+ }
785+ var toCache []toCacheItem
786+
787+ for i , q := range queries {
788+ // Check cache first
789+ if res := m .cache .Get (q ); res != nil {
790+ m .updateCacheStats (true )
791+ results [i ] = res
792+ continue
793+ }
794+
795+ m .updateCacheStats (false )
796+
797+ matches , err := m .findAllMatchesNoLock (q )
798+ if err != nil {
799+ return nil , err
800+ }
801+
802+ if len (matches ) == 0 {
803+ results [i ] = nil
804+ continue
805+ }
806+
807+ best := matches [0 ]
808+ results [i ] = best
809+ toCache = append (toCache , toCacheItem {q : q , best : best })
810+ }
811+
812+ // Populate cache for computed results
813+ for _ , item := range toCache {
814+ m .cache .Set (item .q , item .best )
815+ }
816+
817+ // Update average query time without lock
818+ m .updateQueryTimeStats (time .Since (start ))
819+
820+ return results , nil
821+ }
822+
723823// dimensionsEqual checks if two rules have identical dimensions
724824func (m * InMemoryMatcher ) dimensionsEqual (rule1 , rule2 * Rule ) bool {
725825 if len (rule1 .Dimensions ) != len (rule2 .Dimensions ) {
@@ -1044,12 +1144,25 @@ func (m *InMemoryMatcher) GetStats() *MatcherStats {
10441144 m .mu .RLock ()
10451145 defer m .mu .RUnlock ()
10461146
1047- // Create a copy to avoid race conditions, using atomic read for TotalQueries
1147+ // Create a copy to avoid race conditions
10481148 stats := * m .stats
1049- stats .TotalQueries = atomic .LoadInt64 (& m .stats .TotalQueries )
1149+ // Guard against divide-by-zero when no queries have been recorded yet
1150+ if stats .TotalQueries > 0 {
1151+ stats .AverageQueryTime = stats .TotalQueryTime / stats .TotalQueries
1152+ } else {
1153+ stats .AverageQueryTime = 0
1154+ }
10501155 return & stats
10511156}
10521157
1158+ // SetAllowDuplicateWeights configures whether rules with duplicate weights are allowed
1159+ // By default, duplicate weights are not allowed to ensure deterministic matching
1160+ func (m * InMemoryMatcher ) SetAllowDuplicateWeights (allow bool ) {
1161+ m .mu .Lock ()
1162+ defer m .mu .Unlock ()
1163+ m .allowDuplicateWeights = allow
1164+ }
1165+
10531166// SaveToPersistence saves current state to persistence layer
10541167func (m * InMemoryMatcher ) SaveToPersistence () error {
10551168 m .mu .RLock ()
0 commit comments