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 }