go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/clustering/rules/cache/ruleset.go (about)

     1  // Copyright 2022 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package cache
    16  
    17  import (
    18  	"context"
    19  	"sort"
    20  	"time"
    21  
    22  	"go.opentelemetry.io/otel/attribute"
    23  
    24  	"go.chromium.org/luci/common/clock"
    25  	"go.chromium.org/luci/common/errors"
    26  	"go.chromium.org/luci/server/span"
    27  
    28  	"go.chromium.org/luci/analysis/internal/clustering/rules"
    29  	"go.chromium.org/luci/analysis/internal/clustering/rules/lang"
    30  	"go.chromium.org/luci/analysis/internal/tracing"
    31  )
    32  
    33  // CachedRule represents a "compiled" version of a failure
    34  // association rule.
    35  // It should be treated as immutable, and is therefore safe to
    36  // share across multiple threads.
    37  type CachedRule struct {
    38  	// The failure association rule.
    39  	Rule rules.Entry
    40  	// The parsed and compiled failure association rule.
    41  	Expr *lang.Expr
    42  }
    43  
    44  // NewCachedRule initialises a new CachedRule from the given failure
    45  // association rule.
    46  func NewCachedRule(rule *rules.Entry) (*CachedRule, error) {
    47  	expr, err := lang.Parse(rule.RuleDefinition)
    48  	if err != nil {
    49  		return nil, err
    50  	}
    51  	return &CachedRule{
    52  		Rule: *rule,
    53  		Expr: expr,
    54  	}, nil
    55  }
    56  
    57  // Ruleset represents a version of the set of failure
    58  // association rules in use by a LUCI Project.
    59  // It should be treated as immutable, and therefore safe to share
    60  // across multiple threads.
    61  type Ruleset struct {
    62  	// The LUCI Project.
    63  	Project string
    64  	// ActiveRulesSorted is the set of active failure association rules
    65  	// (should be used by LUCI Analysis for matching), sorted in descending
    66  	// PredicateLastUpdated time order.
    67  	ActiveRulesSorted []*CachedRule
    68  	// ActiveRulesByID stores active failure association
    69  	// rules by their Rule ID.
    70  	ActiveRulesByID map[string]*CachedRule
    71  	// Version versions the contents of the Ruleset. These timestamps only
    72  	// change if a rule is modified.
    73  	Version rules.Version
    74  	// LastRefresh contains the monotonic clock reading when the last ruleset
    75  	// refresh was initiated. The refresh is guaranteed to contain all rules
    76  	// changes made prior to this timestamp.
    77  	LastRefresh time.Time
    78  }
    79  
    80  // ActiveRulesWithPredicateUpdatedSince returns the set of rules that are
    81  // active and whose predicates have been updated since (but not including)
    82  // the given time.
    83  // Rules which have been made inactive since the given time will NOT be
    84  // returned. To check if a previous rule has been made inactive, consider
    85  // using IsRuleActive instead.
    86  // The returned slice must not be mutated.
    87  func (r *Ruleset) ActiveRulesWithPredicateUpdatedSince(t time.Time) []*CachedRule {
    88  	// Use the property that ActiveRules is sorted by descending
    89  	// LastUpdated time.
    90  	for i, rule := range r.ActiveRulesSorted {
    91  		if !rule.Rule.PredicateLastUpdateTime.After(t) {
    92  			// This is the first rule that has not been updated since time t.
    93  			// Return all rules up to (but not including) this rule.
    94  			return r.ActiveRulesSorted[:i]
    95  		}
    96  	}
    97  	return r.ActiveRulesSorted
    98  }
    99  
   100  // Returns whether the given ruleID is an active rule.
   101  func (r *Ruleset) IsRuleActive(ruleID string) bool {
   102  	_, ok := r.ActiveRulesByID[ruleID]
   103  	return ok
   104  }
   105  
   106  // newEmptyRuleset initialises a new empty ruleset.
   107  // This initial ruleset is invalid and must be refreshed before use.
   108  func newEmptyRuleset(project string) *Ruleset {
   109  	return &Ruleset{
   110  		Project:           project,
   111  		ActiveRulesSorted: nil,
   112  		ActiveRulesByID:   make(map[string]*CachedRule),
   113  		// The zero predicate last updated time is not valid and will be
   114  		// rejected by clustering state validation if we ever try to save
   115  		// it to Spanner as a chunk's RulesVersion.
   116  		Version:     rules.Version{},
   117  		LastRefresh: time.Time{},
   118  	}
   119  }
   120  
   121  // NewRuleset creates a new ruleset with the given project,
   122  // active rules, rules last updated and last refresh time.
   123  func NewRuleset(project string, activeRules []*CachedRule, version rules.Version, lastRefresh time.Time) *Ruleset {
   124  	return &Ruleset{
   125  		Project:           project,
   126  		ActiveRulesSorted: sortByDescendingPredicateLastUpdated(activeRules),
   127  		ActiveRulesByID:   rulesByID(activeRules),
   128  		Version:           version,
   129  		LastRefresh:       lastRefresh,
   130  	}
   131  }
   132  
   133  // refresh updates the ruleset. To ensure existing users of the rulset
   134  // do not observe changes while they are using it, a new copy is returned.
   135  func (r *Ruleset) refresh(ctx context.Context) (ruleset *Ruleset, err error) {
   136  	// Under our design assumption of 10,000 active rules per project,
   137  	// pulling and compiling all rules could take a meaningful amount
   138  	// of time (@ 1KB per rule, = ~10MB).
   139  	ctx, s := tracing.Start(ctx, "go.chromium.org/luci/analysis/internal/clustering/rules/cache.Refresh",
   140  		attribute.String("project", r.Project),
   141  	)
   142  	defer func() { tracing.End(s, err) }()
   143  
   144  	// Use clock reading before refresh. The refresh is guaranteed
   145  	// to contain all rule changes committed to Spanner prior to
   146  	// this timestamp.
   147  	lastRefresh := clock.Now(ctx)
   148  
   149  	txn, cancel := span.ReadOnlyTransaction(ctx)
   150  	defer cancel()
   151  
   152  	var activeRules []*CachedRule
   153  	if r.Version == (rules.Version{}) {
   154  		// On the first refresh, query all active rules.
   155  		ruleRows, err := rules.ReadActive(txn, r.Project)
   156  		if err != nil {
   157  			return nil, err
   158  		}
   159  		activeRules, err = cachedRulesFromFullRead(ruleRows)
   160  		if err != nil {
   161  			return nil, err
   162  		}
   163  	} else {
   164  		// On subsequent refreshes, query just the differences.
   165  		delta, err := rules.ReadDelta(txn, r.Project, r.Version.Total)
   166  		if err != nil {
   167  			return nil, err
   168  		}
   169  		activeRules, err = cachedRulesFromDelta(r.ActiveRulesSorted, delta)
   170  		if err != nil {
   171  			return nil, err
   172  		}
   173  	}
   174  
   175  	// Get the version of set of rules read by ReadActive/ReadDelta.
   176  	// Must occur in the same spanner transaction as ReadActive/ReadDelta.
   177  	// If the project has no rules, this returns rules.StartingEpoch.
   178  	rulesVersion, err := rules.ReadVersion(txn, r.Project)
   179  	if err != nil {
   180  		return nil, err
   181  	}
   182  
   183  	return NewRuleset(r.Project, activeRules, rulesVersion, lastRefresh), nil
   184  }
   185  
   186  // cachedRulesFromFullRead obtains a set of cached rules from the given set of
   187  // active failure association rules.
   188  func cachedRulesFromFullRead(activeRules []*rules.Entry) ([]*CachedRule, error) {
   189  	var result []*CachedRule
   190  	for _, r := range activeRules {
   191  		cr, err := NewCachedRule(r)
   192  		if err != nil {
   193  			return nil, errors.Annotate(err, "rule %s is invalid", r.RuleID).Err()
   194  		}
   195  		result = append(result, cr)
   196  	}
   197  	return result, nil
   198  }
   199  
   200  // cachedRulesFromDelta applies deltas to an existing list of rules,
   201  // to obtain an updated set of rules.
   202  func cachedRulesFromDelta(existing []*CachedRule, delta []*rules.Entry) ([]*CachedRule, error) {
   203  	ruleByID := make(map[string]*CachedRule)
   204  	for _, r := range existing {
   205  		ruleByID[r.Rule.RuleID] = r
   206  	}
   207  	for _, d := range delta {
   208  		if d.IsActive {
   209  			cr, err := NewCachedRule(d)
   210  			if err != nil {
   211  				return nil, errors.Annotate(err, "rule %s is invalid", d.RuleID).Err()
   212  			}
   213  			ruleByID[d.RuleID] = cr
   214  		} else {
   215  			// Delete the rule, if it exists.
   216  			delete(ruleByID, d.RuleID)
   217  		}
   218  	}
   219  	var results []*CachedRule
   220  	for _, r := range ruleByID {
   221  		results = append(results, r)
   222  	}
   223  	return results, nil
   224  }
   225  
   226  // sortByDescendingPredicateLastUpdated sorts the given rules in descending
   227  // predicate last-updated time order. If two rules have the same
   228  // PredicateLastUpdated time, they are sorted in RuleID order.
   229  func sortByDescendingPredicateLastUpdated(rules []*CachedRule) []*CachedRule {
   230  	result := make([]*CachedRule, len(rules))
   231  	copy(result, rules)
   232  	sort.Slice(result, func(i, j int) bool {
   233  		if result[i].Rule.PredicateLastUpdateTime.Equal(result[j].Rule.PredicateLastUpdateTime) {
   234  			return result[i].Rule.RuleID < result[j].Rule.RuleID
   235  		}
   236  		return result[i].Rule.PredicateLastUpdateTime.After(result[j].Rule.PredicateLastUpdateTime)
   237  	})
   238  	return result
   239  }
   240  
   241  // rulesByID creates a mapping from rule ID to rules for the given list
   242  // of failure association rules.
   243  func rulesByID(rules []*CachedRule) map[string]*CachedRule {
   244  	result := make(map[string]*CachedRule)
   245  	for _, r := range rules {
   246  		result[r.Rule.RuleID] = r
   247  	}
   248  	return result
   249  }