go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/quotabeta/quota.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 quota 16 17 import ( 18 "bytes" 19 "context" 20 "crypto/sha256" 21 "fmt" 22 "strings" 23 "text/template" 24 25 "github.com/gomodule/redigo/redis" 26 27 "go.chromium.org/luci/common/clock" 28 "go.chromium.org/luci/common/errors" 29 30 pb "go.chromium.org/luci/server/quotabeta/proto" 31 "go.chromium.org/luci/server/quotabeta/quotaconfig" 32 "go.chromium.org/luci/server/redisconn" 33 ) 34 35 // ErrInsufficientQuota is returned by UpdateQuota when the updates were not 36 // applied due to insufficient quota. 37 var ErrInsufficientQuota = errors.New("insufficient quota") 38 39 var cfgKey = "cfg" 40 41 // Use returns a context.Context directing this package to use the given 42 // quotaconfig.Interface. 43 func Use(ctx context.Context, cfg quotaconfig.Interface) context.Context { 44 return context.WithValue(ctx, &cfgKey, cfg) 45 } 46 47 // getInterface returns the quotaconfig.Interface available in the given 48 // context.Context. Panics if no quotaconfig.Interface is available (see Use). 49 func getInterface(ctx context.Context) quotaconfig.Interface { 50 cfg, ok := ctx.Value(&cfgKey).(quotaconfig.Interface) 51 if !ok { 52 panic(errors.Reason("quotaconfig.Interface implementation not found (ensure quota.Use is called in server.Main)").Err()) 53 } 54 return cfg 55 } 56 57 type Options struct { 58 // RequestID is a string to use for deduplication of successful quota 59 // adjustments, valid for one hour. Only successful updates are deduplicated, 60 // mainly for the case where time has passed and quota has replenished 61 // enough for a previously failed adjustment to succeed. 62 // 63 // Callers should ensure the same Options and function parameters are provided 64 // each time a request ID is reused, since any reuse within a one hour period 65 // is subject to deduplication regardless of other Options and parameters. 66 RequestID string 67 // User is a value to substitute for ${user} in policy names. 68 // Policy defaults are sourced from the unsubstituted name. 69 User string 70 } 71 72 // dedupePrefix is a *template.Template for a Lua script which fetches the given 73 // deduplication key from Redis, returning if it exists and is temporally valid. 74 // Prefix to other scripts to create a script which exits early when the 75 // deduplication key is valid. Scripts should be suffixed with dedupeSuffix to 76 // save the deduplication key. 77 // 78 // Template variables: 79 // Key: A string to use for deduplication. 80 // Now: Current time in seconds since epoch. 81 var dedupePrefix = template.Must(template.New("dedupePrefix").Parse(` 82 local deadline = redis.call("HINCRBY", "deduplicationKeys", "{{.Key}}", 0) 83 if deadline >= {{.Now}} then 84 return 85 end 86 `)) 87 88 // dedupeSuffix is a *template.Template for a Lua script which writes the given 89 // deduplication key to Redis. Should be used with dedupePrefix. 90 // 91 // Template variables: 92 // Key: A string to use for deduplication. 93 // Deadline: Time in seconds since epoch after which the key no longer dedupes. 94 var dedupeSuffix = template.Must(template.New("dedupeSuffix").Parse(` 95 redis.call("HMSET", "deduplicationKeys", "{{.Key}}", {{.Deadline}}) 96 `)) 97 98 // updateEntry is a *template.Template for a Lua script which fetches the given 99 // quota entry from Redis, initializing it if it doesn't exist, replenishes 100 // quota since the last update, and updates the amount. Does not modify the 101 // entry in the database, instead entries must explicitly be stored by 102 // concatenating this script with setEntry. This enables atomic updates of 103 // multiple entries by running a script which concatenates multiple updateEntry 104 // calls followed by corresponding setEntry calls. Such a script updates entries 105 // in the database iff all updates would succeed. 106 // 107 // Template variables: 108 // Var: Name of a Lua variable to store the quota entry in memory. 109 // Name: Name of the quota entry to update. 110 // Default: Default number of resources to initialize new entries with. 111 // Now: Update time in seconds since epoch. 112 // Replenishment: Amount of resources to replenish every second. 113 // Amount: Amount by which to update resources. 114 var updateEntry = template.Must(template.New("updateEntry").Parse(` 115 {{.Var}} = {} 116 {{.Var}}["name"] = "{{.Name}}" 117 118 -- Check last updated time to determine if this entry exists. 119 {{.Var}}["updated"] = redis.call("HINCRBY", "{{.Name}}", "updated", 0) 120 if {{.Var}}["updated"] == 0 then 121 -- Delete the updated time of 0 to avoid leaving partial entries 122 -- in the database in case of error. 123 redis.call("DEL", "{{.Name}}") 124 {{.Var}}["resources"] = {{.Default}} 125 {{.Var}}["updated"] = {{.Now}} 126 elseif {{.Var}}["updated"] > {{.Now}} then 127 return redis.error_reply("\"{{.Name}}\" last updated in the future") 128 else 129 {{.Var}}["resources"] = redis.call("HINCRBY", "{{.Name}}", "resources", 0) 130 end 131 132 -- Replenish resources up to the cap before updating. 133 {{.Var}}["resources"] = {{.Var}}["resources"] + ({{.Now}} - {{.Var}}["updated"]) * {{.Replenishment}} 134 {{.Var}}["updated"] = {{.Now}} 135 -- Cap resources at the default amount. 136 if {{.Var}}["resources"] > {{.Default}} then 137 {{.Var}}["resources"] = {{.Default}} 138 end 139 140 -- Check that the update would succeed before updating. 141 if {{.Var}}["resources"] + {{.Amount}} < 0 then 142 return redis.error_reply("\"{{.Name}}\" has insufficient resources") 143 end 144 145 -- Update resources up to the cap. 146 {{.Var}}["resources"] = {{.Var}}["resources"] + {{.Amount}} 147 {{.Var}}["updated"] = {{.Now}} 148 -- Cap resources at the default amount. 149 if {{.Var}}["resources"] > {{.Default}} then 150 {{.Var}}["resources"] = {{.Default}} 151 end 152 `)) 153 154 // setEntry is a *template.Template for a Lua script which sets the given quota 155 // entry in Redis. Should be used after updateEntry. 156 // 157 // Template variables: 158 // Var: Name of a Lua variable holding the quota entry in memory. 159 var setEntry = template.Must(template.New("setEntry").Parse(` 160 redis.call("HMSET", {{.Var}}["name"], "resources", {{.Var}}["resources"], "updated", {{.Var}}["updated"]) 161 `)) 162 163 // UpdateQuota atomically adjusts the given quota entries using the given map of 164 // policy names to numeric update amounts as well as the given *Options. Returns 165 // ErrInsufficientQuota when the adjustments were not made due to insufficient 166 // quota. 167 // 168 // Panics if quotaconfig.Interface is not available in the given context.Context 169 // (see WithConfig). 170 func UpdateQuota(ctx context.Context, updates map[string]int64, opts *Options) error { 171 now := clock.Now(ctx).Unix() 172 cfg := getInterface(ctx) 173 174 defs := make(map[string]*pb.Policy, len(updates)) 175 adjs := make(map[string]int64, len(updates)) 176 177 i := 0 178 for pol, val := range updates { 179 name := pol 180 if strings.Contains(pol, "${user}") { 181 if opts == nil || opts.User == "" { 182 return errors.Reason("user unspecified for %q", pol).Err() 183 } 184 name = strings.ReplaceAll(name, "${user}", opts.User) 185 } 186 name = fmt.Sprintf("entry:%x", sha256.Sum256([]byte(name))) 187 def, err := cfg.Get(ctx, pol) 188 if err != nil { 189 return errors.Annotate(err, "fetching config").Err() 190 } 191 defs[name] = def 192 adjs[name] = val 193 i++ 194 } 195 196 conn, err := redisconn.Get(ctx) 197 if err != nil { 198 return errors.Annotate(err, "establishing connection").Err() 199 } 200 defer conn.Close() 201 202 s := bytes.NewBufferString("local entries = {}\n") 203 if opts != nil && opts.RequestID != "" { 204 if err := dedupePrefix.Execute(s, map[string]any{ 205 "Key": opts.RequestID, 206 "Now": now, 207 }); err != nil { 208 return errors.Annotate(err, "rendering template %q", dedupePrefix.Name()).Err() 209 } 210 } 211 212 i = 0 213 for name, adj := range adjs { 214 if err := updateEntry.Execute(s, map[string]any{ 215 "Var": fmt.Sprintf("entries[%d]", i), 216 "Name": name, 217 "Default": defs[name].Resources, 218 "Now": now, 219 "Replenishment": defs[name].Replenishment, 220 "Amount": adj, 221 }); err != nil { 222 return errors.Annotate(err, "rendering template %q", updateEntry.Name()).Err() 223 } 224 i++ 225 } 226 for i--; i >= 0; i-- { 227 if err := setEntry.Execute(s, map[string]any{ 228 "Var": fmt.Sprintf("entries[%d]", i), 229 }); err != nil { 230 return errors.Annotate(err, "rendering template %q", setEntry.Name()).Err() 231 } 232 } 233 234 if opts != nil && opts.RequestID != "" { 235 if err := dedupeSuffix.Execute(s, map[string]any{ 236 "Key": opts.RequestID, 237 "Deadline": now + 3600, 238 }); err != nil { 239 return errors.Annotate(err, "rendering template %q", dedupeSuffix.Name()).Err() 240 } 241 } 242 243 if _, err := redis.NewScript(0, s.String()).Do(conn); err != nil { 244 if strings.HasSuffix(err.Error(), "insufficient resources") { 245 return ErrInsufficientQuota 246 } 247 return err 248 } 249 return nil 250 }