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  }