go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/quota/internal/lua/account.lua (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  -- expect to have PB and Utils passed in
    16  local PB, Utils, Policy = ...
    17  assert(PB)
    18  assert(Utils)
    19  assert(Policy)
    20  
    21  local Account = {
    22    -- Account key => {
    23    --   key: (redis key),
    24    --   pb: PB go.chromium.org.luci.server.quota.Account,
    25    --   account_status: AccountStatus string,
    26    -- }
    27    CACHE = {},
    28  }
    29  
    30  -- TODO(iannucci) - make local references for all readonly dot-access stuff?
    31  local math_floor = math.floor
    32  local math_min = math.min
    33  local math_max = math.max
    34  
    35  local NOW = Utils.NOW
    36  local redis_call = redis.call
    37  
    38  local AccountPB = "go.chromium.org.luci.server.quota.quotapb.Account"
    39  
    40  function Account:get(key)
    41    assert(key, "Account:get called with <nil>")
    42    assert(key ~= "", "Account:get called with ''")
    43    local entry = Account.CACHE[key]
    44    if not entry then
    45      local raw = redis_call("GET", key)
    46      entry = {
    47        key = key,
    48        -- NOTE: could keep track of whether this Account needs to be written, but
    49        -- if we are loading it, it means that the user has elected to perform an
    50        -- Op on this Account. Only in rather pathological cases would this result
    51        -- in a total no op (no refill, Op e.g. subtracts 0 to the balance). So,
    52        -- we elect to just always write all loaded Accounts at the end of the
    53        -- update script.
    54      }
    55      if raw then
    56        local ok, pb = pcall(PB.unmarshal, AccountPB, raw)
    57        if ok then
    58          entry.pb = pb
    59          entry.account_status = "ALREADY_EXISTS"
    60        else
    61          -- NOTE: not-ok implies that this account is broken; we treat this the
    62          -- same as a non-existant account (i.e. we'll make a new one).
    63          entry.account_status = "RECREATED"
    64        end
    65      else
    66        entry.account_status = "CREATED"
    67      end
    68      if not entry.pb then
    69        entry.pb = PB.new(AccountPB, {
    70          -- This Account will be (re)created, so it's updated_ts is NOW.
    71          updated_ts = NOW,
    72        })
    73      end
    74      setmetatable(entry, self)
    75      self.__index = self
    76      self.CACHE[key] = entry
    77  
    78      -- The entry was not new; it may have some refill policy to apply.
    79      --
    80      -- We set updated_ts here and not in applyRefill because the way refill is
    81      -- defined; We do the refill, under the existing policy, exactly once when
    82      -- the Account is loaded. Note that the existing policy could be missing,
    83      -- infinite, or just a regular refill policy. In ALL of those cases, we want
    84      -- any future policy application which happens in this invocation to
    85      -- calculate vs NOW, not vs any previous updated_ts time.
    86      --
    87      -- We could move this to applyRefill... but here it is a clearer indicator
    88      -- that NO applyRefill calls (e.g. in the case of applying an infinite
    89      -- refill policy via an Op) in this process will use any previous
    90      -- updated_ts.
    91      --
    92      -- You will note that updated_ts is only ever set to NOW in this whole
    93      -- program, and both of those assignments happen here, in this constructor.
    94      if entry.account_status == "ALREADY_EXISTS" then
    95        entry:applyRefill()
    96        entry.pb.updated_ts = NOW
    97      end
    98    end
    99    return entry
   100  end
   101  
   102  local RELATIVE_TO_CURRENT_BALANCE = "CURRENT_BALANCE"
   103  local RELATIVE_TO_DEFAULT = "DEFAULT"
   104  local RELATIVE_TO_LIMIT = "LIMIT"
   105  local RELATIVE_TO_ZERO = "ZERO"
   106  
   107  -- isInfiniteRefill determines if the policy has a refill policy, and if so, if
   108  -- it is positively infinite.
   109  --
   110  -- An 'infinite refill policy' is one where the `interval` (i.e. how frequently
   111  -- `units` apply is 0) and `units` is positive. This is because the policy would
   112  -- logically fill the account at a rate of `units / interval` (thus answering
   113  -- the age-old question of what happens when you divide by zero).
   114  --
   115  -- It is not valid to have an interval of 0 with units <= 0. "Negative Infinity"
   116  -- policies are better represented by just setting the policy limit to 0. This
   117  -- function raises an error in such a case.
   118  local isInfiniteRefill = function(policy)
   119    if not policy then
   120      return false
   121    end
   122  
   123    if not policy.refill then
   124      return false
   125    end
   126  
   127    if policy.refill.interval ~= 0 then
   128      return false
   129    end
   130  
   131    if policy.refill.units <= 0 then
   132      error("invalid zero-interval refill policy")
   133    end
   134  
   135    return true
   136  end
   137  
   138  local opts = PB.E["go.chromium.org.luci.server.quota.quotapb.Op.Options"]
   139  local IGNORE_POLICY_BOUNDS = opts.IGNORE_POLICY_BOUNDS
   140  local DO_NOT_CAP_PROPOSED = opts.DO_NOT_CAP_PROPOSED
   141  local WITH_POLICY_LIMIT_DELTA = opts.WITH_POLICY_LIMIT_DELTA
   142  
   143  -- computeProposed computes the new, proposed, balance value for an account.
   144  --
   145  -- Args:
   146  --   * op -- The Op message
   147  --   * new_account (bool) -- True if this acccount is 'new' (i.e. prior to this
   148  --     Op, the Account did not exist)
   149  --   * current (number) -- The balance of the account (undefined for new
   150  --     accounts).
   151  --   * limit (number or nil) -- The limit of the account's policy, or nil if the
   152  --     account has no policy.
   153  --   * default (number or nil) -- The default for a new account, or nil if the
   154  --     account has no policy.
   155  --
   156  -- Returns the proposed account balance (number) plus a status string (if there
   157  -- was a error). If `op` is malformed, this raises an error.
   158  local computeProposed = function(op, new_account, current, policy)
   159    local relative_to = op.relative_to
   160    if relative_to == RELATIVE_TO_ZERO then
   161      return op.delta
   162    end
   163  
   164    if policy == nil and new_account then
   165      -- no policy and this account didn't exist? `current` is undefined, since we
   166      -- don't know what its default should be.
   167      return 0, "ERR_POLICY_REQUIRED"
   168    end
   169  
   170    if relative_to == RELATIVE_TO_CURRENT_BALANCE then
   171      return current + op.delta
   172    end
   173  
   174    if policy == nil then
   175      return 0, "ERR_POLICY_REQUIRED"
   176    end
   177  
   178    if relative_to == RELATIVE_TO_LIMIT then
   179      return policy.limit + op.delta
   180    end
   181  
   182    if relative_to == RELATIVE_TO_DEFAULT then
   183      return policy.default + op.delta
   184    end
   185  
   186    error("invalid `relative_to` value: "..op.relative_to)
   187  end
   188  
   189  function Account:applyOp(op, result)
   190    -- this weird construction checks if the bit is set in options.
   191    local options = op.options
   192    local ignore_bounds = (options/IGNORE_POLICY_BOUNDS)%2 >= 1
   193    local no_cap = (options/DO_NOT_CAP_PROPOSED)%2 >= 1
   194    local with_policy_limit_delta = (options/WITH_POLICY_LIMIT_DELTA)%2 >= 1
   195  
   196    -- If there is a policy_ref attempt to set the policy.
   197    if op.policy_ref ~= nil then
   198      local policy_raw = Policy.get(op.policy_ref)
   199      if not policy_raw then
   200        result.status = "ERR_UNKNOWN_POLICY"
   201        return
   202      end
   203      self:setPolicy(policy_raw, result, with_policy_limit_delta)
   204    end
   205    local pb = self.pb
   206    local policy = pb.policy
   207  
   208    if ignore_bounds and no_cap then
   209      error("IGNORE_POLICY_BOUNDS and DO_NOT_CAP_PROPOSED both set")
   210    end
   211  
   212    local current = pb.balance
   213  
   214    -- step 1; figure out what value they want to set the account to.
   215    -- NOTE: computeProposed will return an error status if the op wants to compute
   216    -- something relative to a value we don't have (e.g. the balance for a `new`
   217    -- Account, or the limit/default for a policy-less Account).
   218    local proposed, status = computeProposed(op, self.account_status ~= "ALREADY_EXISTS", current, policy)
   219    if status ~= nil then
   220      result.status = status
   221      return
   222    end
   223  
   224    local limit = nil
   225    if policy then
   226      limit = policy.limit
   227    end
   228    if not (no_cap or ignore_bounds) then
   229      -- We haven't been instructed to cap the proposed value by policy.limit, and
   230      -- we also haven't been instructed to completely ignore the bounds.
   231      proposed = math_min(proposed, limit)
   232    end
   233  
   234    -- step 2, figure out how to apply the proposed value.
   235    if ignore_bounds then
   236      -- No boundaries matter, use the proposed value as-is.
   237      pb.balance = proposed
   238    elseif policy == nil then
   239      -- You cannot apply a value to an Account lacking a policy, without using
   240      -- the ignore_bounds flag, so check that here.
   241      result.status = "ERR_POLICY_REQUIRED"
   242      return
   243    elseif proposed >= 0 and proposed <= limit then
   244      -- We are respecting policy bounds, and this proposed value is "in bounds".
   245      if isInfiniteRefill(policy) then
   246        -- setting a value 'in bounds' with an infinite policy means that the
   247        -- balance automatically replenishes to policy.limit. Note that pb.balance
   248        -- could, itself, be out of bounds here (i.e. balance could be negative or
   249        -- over the limit), but the proposal puts us in [0, limit], whereupon the
   250        -- policy.refill now applies.
   251        pb.balance = limit
   252      else
   253        pb.balance = proposed
   254      end
   255    else
   256      -- We are respecting policy bounds, and this proposed value is "out of bounds"
   257      -- (either below 0 or above policy.limit).
   258      --
   259      -- We allow updates which bring the account balance back towards [0, limit].
   260      -- That is, we allow a credit to a negative account balance, and we allow
   261      -- a debit from an overflowed account balance.
   262      --
   263      -- However we wouldn't allow making a negative balance MORE negative or an
   264      -- overflowed balance MORE overflowed.
   265      --
   266      -- NOTE: Even with an infinite refill policy, you are not allowed to reduce
   267      -- the balance below 0 without UNDERFLOW-ing.
   268      if proposed < 0 and proposed < current then
   269        result.status = "ERR_UNDERFLOW"
   270        return
   271      end
   272      if proposed > limit and proposed > current then
   273        result.status = "ERR_OVERFLOW"
   274        return
   275      end
   276      pb.balance = proposed
   277    end
   278  
   279    -- We applied an op without error; the Account is now "ALREADY_EXISTS" for any
   280    -- subsequent ops.
   281    self.account_status = "ALREADY_EXISTS"
   282  end
   283  
   284  function Account:applyRefill()
   285    -- NOTE: in this function we assume that policy has already been validated and
   286    -- follows all validation rules set in `policy.proto`.
   287    local policy = self.pb.policy
   288    if policy == nil then
   289      return
   290    end
   291    local limit = policy.limit
   292  
   293    local refill = policy.refill
   294    if refill == nil then
   295      return
   296    end
   297  
   298    local curBalance = self.pb.balance
   299  
   300    if isInfiniteRefill(policy) then
   301      self.pb.balance = math_max(curBalance, limit)
   302      return
   303    end
   304  
   305    local units = refill.units
   306    local interval = refill.interval
   307  
   308    -- we subtract offset from all timestamps for this calculation; this has the
   309    -- effect of shifting midnight forwards by pushing all the timestamps
   310    -- backwards.
   311    --
   312    -- NOTE - Because interval and offset are defined as UTC midnight aligned
   313    -- seconds, we can completely ignore the nanos field in both updated_ts and
   314    -- NOW, since they would be obliterated by the % interval logic below anyway.
   315    local offset = refill.offset
   316    local updated_unix = self.pb.updated_ts.seconds - offset
   317    local now_unix = NOW.seconds - offset
   318  
   319    -- Intervals:  A     B     C     D
   320    -- Timestamps:    U          N
   321    --
   322    -- first_event_unix == B
   323    -- last_event_unix == C
   324    --
   325    -- num_events == 2
   326  
   327    -- find first refill event after updated_ts
   328    local first_event_unix = (updated_unix - (updated_unix % interval)) + interval
   329    -- find last refill event which happened before NOW
   330    local last_event_unix = (now_unix - (now_unix % interval))
   331  
   332    if last_event_unix < first_event_unix then
   333      return
   334    end
   335  
   336    local num_events = ((last_event_unix - first_event_unix) / interval) + 1
   337    -- we should always have an integer number of events
   338    assert(math_floor(num_events) == num_events)
   339  
   340    local delta = num_events * units
   341    if delta > 0 and curBalance < limit then
   342      self.pb.balance = math_min(curBalance+delta, limit)
   343    elseif delta < 0 and curBalance > 0 then
   344      self.pb.balance = math_max(curBalance+delta, 0)
   345    end
   346  end
   347  
   348  local policyRefEq = function(a, b)
   349    if a == nil and b == nil then
   350      return true
   351    end
   352    if a == nil and b ~= nil then
   353      return false
   354    end
   355    if a ~= nil and b == nil then
   356      return false
   357    end
   358    return a.config == b.config and a.key == b.key
   359  end
   360  
   361  function Account:setPolicy(policy, result, with_policy_limit_delta)
   362    if policy then
   363      -- sets Policy on this Account, updating its policy entry and replenishing it.
   364      if not policyRefEq(self.pb.policy_ref, policy.policy_ref) then
   365        -- When with_policy_limit_delta is set for an account with an existing
   366        -- policy_ref, and there is a policy update, add the delta of the new
   367        -- policy limit and the old policy limit to the current account balance.
   368        if with_policy_limit_delta and self.pb.policy_ref ~= nil then
   369          local delta = policy.pb.limit - self.pb.policy.limit
   370          self.pb.balance = self.pb.balance + delta
   371  
   372          -- Update previous_balance_adjusted to the updated balance.
   373          result.previous_balance_adjusted = self.pb.balance
   374        end
   375  
   376        self.pb.policy = policy.pb
   377        self.pb.policy_ref = policy.policy_ref
   378        self.pb.policy_change_ts = NOW
   379      end
   380  
   381      -- this account didn't exist before; set the initial value.
   382      if self.account_status ~= "ALREADY_EXISTS" then
   383        -- if multiple ops apply to this Account, treat the account as existing
   384        -- from this point on.
   385        self.pb.balance = policy.pb.default
   386      end
   387  
   388      self:applyRefill() -- in case this policy is infinite, this will set the balance to limit.
   389    else
   390      -- explicitly unset the policy; no further action is needed.
   391      self.pb.policy = nil
   392      self.pb.policy_ref = nil
   393      self.pb.policy_change_ts = NOW
   394    end
   395  end
   396  
   397  function Account:write()
   398    local lifetime_ms = nil
   399    if self.pb.policy then
   400      lifetime_ms = Utils.Millis(self.pb.policy.lifetime)
   401    end
   402  
   403    local raw = PB.marshal(self.pb)
   404    if lifetime_ms then
   405      redis_call('SET', self.key, raw, 'PX', lifetime_ms)
   406    else
   407      redis_call('SET', self.key, raw)
   408    end
   409  end
   410  
   411  function Account.ApplyOps(oplist)
   412    local ret = PB.new("go.chromium.org.luci.server.quota.quotapb.ApplyOpsResponse")
   413    local allOK = true
   414    for i, op in ipairs(oplist) do
   415      local account = Account:get(op.account_ref)
   416  
   417      local result = PB.new("go.chromium.org.luci.server.quota.quotapb.OpResult", {
   418        status = "SUCCESS",  -- by default; applyOp can overwrite this.
   419        account_status = account.account_status,
   420        previous_balance = account.pb.balance,
   421        -- This will be updated if WITH_POLICY_LIMIT_DELTA is set, the account
   422        -- has an existing policy_ref, and the op introduces a policy change.
   423        previous_balance_adjusted = account.pb.balance,
   424      })
   425      ret.results[i] = result
   426  
   427      -- applyOp should never raise an error, so any caught here are unknown.
   428      local ok, err = pcall(account.applyOp, account, op, result)
   429      if not ok then
   430        result.status = "ERR_UNKNOWN"
   431        result.status_msg = tostring(err)
   432      end
   433  
   434      if result.status == "SUCCESS" then
   435        result.new_balance = account.pb.balance
   436      else
   437        allOK = false
   438      end
   439    end
   440  
   441    if allOK then
   442      for key, account in pairs(Account.CACHE) do
   443        account:write()
   444      end
   445      ret.originally_set = NOW
   446    end
   447  
   448    return ret, allOK
   449  end
   450  
   451  return Account