cleaned up usage windows
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
-- ============================================================================
|
||||
-- USAGE WINDOW CONTEXT UTILITIES (V2)
|
||||
-- Hard windowed usage-limit enforcement against context.usage_windows (loaded
|
||||
-- by init_context via read_usage_windows), integrated into the deduction
|
||||
-- passes the same way spend limits are:
|
||||
-- per-ent gate (get_available_from_usage_windows, in the deduction loop)
|
||||
-- -> consume headroom as each ent drains (consume_usage_window_headroom)
|
||||
-- -> update_in_memory_usage_window (mark dirty) -> apply_usage_window_writes.
|
||||
-- A window-capped leftover is handled by the standard overage_behaviour path
|
||||
-- ('cap' applies the partial deduction, 'reject' returns INSUFFICIENT_BALANCE)
|
||||
-- -- no window-specific error.
|
||||
--
|
||||
-- CONFIG IN: usage_window_limits[] -- resolved caps (limit, bounds,
|
||||
-- dimension) from fullSubjectToUsageWindowLimits.
|
||||
-- COUNTERS OUT: context.usage_windows[feature_id].windows -- DbUsageWindow
|
||||
-- rows (usage amounts), NOT the config.
|
||||
--
|
||||
-- Units: the deduction loop works in TRACKED-FEATURE UNITS; each ent's
|
||||
-- credit_cost converts them to that ent's balance units. A metered_feature
|
||||
-- limit counts tracked units (applies to every ent in the deduction set); a
|
||||
-- balance limit counts credits drained from ents OF the capped feature, so
|
||||
-- headroom converts via credit_cost at the gate.
|
||||
-- ============================================================================
|
||||
|
||||
-- Tolerance for float drift (credit-ratio conversions leave sub-nano noise).
|
||||
local USAGE_WINDOW_EPSILON = 1e-9
|
||||
|
||||
-- Max tracked units deductible from ONE ent given every applicable window
|
||||
-- limit, or nil when unbounded. Windows never store a conversion -- headroom
|
||||
-- lives in the limit's own unit (tracked units for metered dims, credits for
|
||||
-- balance dims) and is converted HERE, per call, with the calling ent's
|
||||
-- credit_cost: the same balance-dim headroom yields different unit allowances
|
||||
-- for ents with different credit ratios, and only ents OF the capped feature
|
||||
-- are bound by it at all. Metered dims need no conversion (the deduction loop
|
||||
-- is denominated in tracked units, whatever pool funds them).
|
||||
--
|
||||
-- Pass ent_feature_id = nil for the rollover phase: metered limits still
|
||||
-- apply (rollover drains consume tracked units), balance limits do not
|
||||
-- (parity with spend limits, whose overage math also ignores rollover
|
||||
-- drains).
|
||||
local function get_available_from_usage_windows(params)
|
||||
local context = params.context
|
||||
local ent_feature_id = params.ent_feature_id
|
||||
local credit_cost = params.credit_cost or 1
|
||||
local allowed = nil
|
||||
|
||||
for feature_id, feature_windows in pairs(context.usage_windows or {}) do
|
||||
local headroom = feature_windows.headroom
|
||||
if headroom <= USAGE_WINDOW_EPSILON then
|
||||
headroom = 0
|
||||
end
|
||||
|
||||
local units = nil
|
||||
if feature_windows.dimension_type ~= 'balance' then
|
||||
units = headroom
|
||||
elseif not is_nil(ent_feature_id) and feature_id == ent_feature_id then
|
||||
units = headroom / credit_cost
|
||||
end
|
||||
|
||||
if units ~= nil and (allowed == nil or units < allowed) then
|
||||
allowed = units
|
||||
end
|
||||
end
|
||||
|
||||
return allowed
|
||||
end
|
||||
|
||||
-- Records `units` tracked units drained from an ent against every applicable
|
||||
-- limit: metered limits consume units 1:1, balance limits consume
|
||||
-- units * credit_cost (credits). Decrements live headroom so the next ent's
|
||||
-- gate sees it, and accumulates `consumed` for the counter increment.
|
||||
local function consume_usage_window_headroom(params)
|
||||
local context = params.context
|
||||
local ent_feature_id = params.ent_feature_id
|
||||
local credit_cost = params.credit_cost or 1
|
||||
local units = params.units or 0
|
||||
|
||||
if units <= 0 then
|
||||
return
|
||||
end
|
||||
|
||||
for feature_id, feature_windows in pairs(context.usage_windows or {}) do
|
||||
local consumed = nil
|
||||
if feature_windows.dimension_type ~= 'balance' then
|
||||
consumed = units
|
||||
elseif not is_nil(ent_feature_id) and feature_id == ent_feature_id then
|
||||
consumed = units * credit_cost
|
||||
end
|
||||
|
||||
if consumed ~= nil and consumed > 0 then
|
||||
feature_windows.headroom = feature_windows.headroom - consumed
|
||||
if feature_windows.headroom < 0 then
|
||||
feature_windows.headroom = 0
|
||||
end
|
||||
feature_windows.consumed = feature_windows.consumed + consumed
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Sibling of append_mutation_log: records which window row moved and by how
|
||||
-- much (usage_delta in the limit's native unit). Kept as its own stream so
|
||||
-- mutation_logs stays entitlement/rollover-shaped.
|
||||
local function append_usage_window_mutation(params)
|
||||
local context = params.context
|
||||
table.insert(context.usage_window_mutations, {
|
||||
usage_window_id = params.usage_window_id or cjson.null,
|
||||
feature_id = params.feature_id,
|
||||
internal_entity_id = params.internal_entity_id or cjson.null,
|
||||
window_start_at = params.window_start_at,
|
||||
usage_delta = params.usage_delta or 0,
|
||||
})
|
||||
end
|
||||
|
||||
-- In-memory mutation for one limit (sibling of
|
||||
-- update_in_memory_customer_entitlement_mutation). ONE mutable row per scope:
|
||||
-- zero the count if its stored window closed (defensive guard -- the lazy
|
||||
-- roll action owns the roll), stamp the current bounds/anchor, add consumed.
|
||||
local function update_in_memory_usage_window(params)
|
||||
local context = params.context
|
||||
local limit = params.limit
|
||||
local now = params.now
|
||||
|
||||
local feature_windows = context.usage_windows[limit.feature_id]
|
||||
if feature_windows == nil then
|
||||
return
|
||||
end
|
||||
|
||||
if feature_windows.consumed > USAGE_WINDOW_EPSILON then
|
||||
local existing = find_usage_window(feature_windows.windows, limit)
|
||||
if is_nil(existing) then
|
||||
-- The TS-minted candidate id is used ONLY at creation; under concurrency
|
||||
-- the second request finds the first one's row and its id is discarded.
|
||||
existing = {
|
||||
id = limit.new_window_id,
|
||||
internal_customer_id = limit.internal_customer_id,
|
||||
internal_entity_id = limit.internal_entity_id,
|
||||
feature_id = limit.feature_id,
|
||||
internal_feature_id = limit.internal_feature_id,
|
||||
usage = 0,
|
||||
}
|
||||
table.insert(feature_windows.windows, existing)
|
||||
elseif safe_number(existing.window_end_at) <= now
|
||||
or safe_number(existing.window_start_at) ~= limit.window_start_at
|
||||
then
|
||||
-- A count never survives its stamped window: zero on expiry AND on any
|
||||
-- bounds re-derivation mismatch (plan change).
|
||||
existing.usage = 0
|
||||
end
|
||||
|
||||
existing.window_start_at = limit.window_start_at
|
||||
existing.window_end_at = limit.window_end_at
|
||||
existing.anchor_customer_entitlement_id =
|
||||
limit.anchor_customer_entitlement_id
|
||||
existing.usage = safe_number(existing.usage) + feature_windows.consumed
|
||||
existing.updated_at = now
|
||||
feature_windows.dirty = true
|
||||
|
||||
append_usage_window_mutation({
|
||||
context = context,
|
||||
usage_window_id = existing.id,
|
||||
feature_id = limit.feature_id,
|
||||
internal_entity_id = limit.internal_entity_id,
|
||||
window_start_at = limit.window_start_at,
|
||||
usage_delta = feature_windows.consumed,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Applies each limit's in-flight consumed amount to its counter row.
|
||||
-- Mirrors a lock UNWIND onto the counters: each applied unwind iteration
|
||||
-- frees window headroom (metered dims by tracked units, balance dims by the
|
||||
-- credits restored to that feature's entitlements). Clamped at 0; only the
|
||||
-- limit's CURRENT window is decremented (a roll between lock and unwind
|
||||
-- forfeits the old window's count, which is the conservative outcome).
|
||||
local function decrement_usage_windows_for_unwind(params)
|
||||
local context = params.context
|
||||
local iterations = safe_table(params.iterations)
|
||||
local now = params.now
|
||||
|
||||
if is_nil(context.usage_windows) or #iterations == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
local total_units = 0
|
||||
local credits_by_feature_id = {}
|
||||
|
||||
for _, iteration in ipairs(iterations) do
|
||||
local units = safe_number(iteration.unwind_iteration_value)
|
||||
total_units = total_units + units
|
||||
|
||||
local item = iteration.item or {}
|
||||
local ent_feature_id = nil
|
||||
local ent = context.customer_entitlements[item.customer_entitlement_id]
|
||||
if ent then
|
||||
ent_feature_id = ent.feature_id
|
||||
elseif item.rollover_id and context.rollovers[item.rollover_id] then
|
||||
local rollover_ent = context.customer_entitlements[
|
||||
context.rollovers[item.rollover_id].cus_ent_id
|
||||
]
|
||||
if rollover_ent then
|
||||
ent_feature_id = rollover_ent.feature_id
|
||||
end
|
||||
end
|
||||
|
||||
if ent_feature_id then
|
||||
local credits = units * safe_number(item.credit_cost or 1)
|
||||
credits_by_feature_id[ent_feature_id] =
|
||||
(credits_by_feature_id[ent_feature_id] or 0) + credits
|
||||
end
|
||||
end
|
||||
|
||||
for feature_id, feature_windows in pairs(context.usage_windows) do
|
||||
local amount = 0
|
||||
if feature_windows.dimension_type == 'balance' then
|
||||
amount = credits_by_feature_id[feature_id] or 0
|
||||
else
|
||||
amount = total_units
|
||||
end
|
||||
|
||||
if amount > 0 then
|
||||
local existing = find_usage_window(
|
||||
feature_windows.windows,
|
||||
feature_windows.limit
|
||||
)
|
||||
if not is_nil(existing) then
|
||||
local current = safe_number(existing.usage)
|
||||
local next_usage = current - amount
|
||||
if next_usage < 0 then
|
||||
next_usage = 0
|
||||
end
|
||||
|
||||
if next_usage ~= current then
|
||||
existing.usage = next_usage
|
||||
existing.updated_at = now
|
||||
feature_windows.dirty = true
|
||||
|
||||
append_usage_window_mutation({
|
||||
context = context,
|
||||
usage_window_id = existing.id,
|
||||
feature_id = feature_windows.limit.feature_id,
|
||||
internal_entity_id = feature_windows.limit.internal_entity_id,
|
||||
window_start_at = feature_windows.limit.window_start_at,
|
||||
usage_delta = next_usage - current,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function increment_usage_window_counters(params)
|
||||
local context = params.context
|
||||
local limits = params.usage_window_limits or {}
|
||||
|
||||
for _, limit in ipairs(limits) do
|
||||
update_in_memory_usage_window({
|
||||
context = context,
|
||||
limit = limit,
|
||||
now = params.now,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Persists dirty counter arrays back to their '_usage_windows' fields
|
||||
-- (sibling of apply_pending_writes; direct HSET like updateAggregatedBalances
|
||||
-- since the cusEnt pending-write path is keyed by entitlement blobs).
|
||||
-- The EXPIRE guard is load-bearing under fail-open: a write to a hash that
|
||||
-- did not exist (capped feature with no entitlements and no rebuild yet)
|
||||
-- must not create an immortal key.
|
||||
local function apply_usage_window_writes(context, ttl_seconds)
|
||||
local ttl = tonumber(ttl_seconds)
|
||||
|
||||
for _, feature_windows in pairs(context.usage_windows or {}) do
|
||||
if feature_windows.dirty and not is_nil(feature_windows.balance_key) then
|
||||
redis.call(
|
||||
'HSET',
|
||||
feature_windows.balance_key,
|
||||
USAGE_WINDOWS_FIELD,
|
||||
cjson.encode(feature_windows.windows)
|
||||
)
|
||||
if ttl and ttl > 0
|
||||
and redis.call('TTL', feature_windows.balance_key) < 0 then
|
||||
redis.call('EXPIRE', feature_windows.balance_key, ttl)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Result payload: { [feature_id] = windows[] } for every loaded capped
|
||||
-- feature, so the TS caller can refresh the in-flight subject and hand the
|
||||
-- post-deduction counters to syncItemV4 (no Redis re-read).
|
||||
local function usage_windows_to_result(context)
|
||||
local result = nil
|
||||
for feature_id, feature_windows in pairs(context.usage_windows or {}) do
|
||||
if result == nil then
|
||||
result = {}
|
||||
end
|
||||
result[feature_id] = feature_windows.windows
|
||||
end
|
||||
return result
|
||||
end
|
||||
Reference in New Issue
Block a user