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