cleaned up usage windows
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
-- ============================================================================
|
||||
-- READ USAGE WINDOWS
|
||||
-- Loads customer-scoped usage-window counter state into the deduction context
|
||||
-- (sibling of read_subject_balances). Counters live in the capped feature's
|
||||
-- balance hash under the reserved '_usage_windows' field, as a lean ARRAY of
|
||||
-- rows mirroring the usage_windows table (DbUsageWindow):
|
||||
-- { id, internal_customer_id, internal_entity_id, feature_id,
|
||||
-- internal_feature_id, anchor_customer_entitlement_id,
|
||||
-- window_start_at, window_end_at, usage, updated_at }
|
||||
--
|
||||
-- Each loaded entry also carries the deduction-time runtime state the per-ent
|
||||
-- gate consumes: the resolved limit, its dimension, the remaining `headroom`
|
||||
-- (limit - current window usage, decremented as the deduction passes drain
|
||||
-- it), and `consumed` (this operation's total, in the limit's native unit).
|
||||
--
|
||||
-- FAIL OPEN: a missing/undecodable field (or an undeclared balance key) loads
|
||||
-- as an empty counter set -- the window simply restarts. Stale-cache guards
|
||||
-- may return in a future iteration.
|
||||
-- ============================================================================
|
||||
|
||||
local USAGE_WINDOWS_FIELD = '_usage_windows'
|
||||
|
||||
-- ONE mutable counter row per scope: a row matches its limit on
|
||||
-- internal_entity_id alone. Bounds are payload, not identity.
|
||||
local function find_usage_window(windows, limit)
|
||||
local limit_entity = limit.internal_entity_id
|
||||
for _, window in ipairs(windows) do
|
||||
if type(window) == 'table' then
|
||||
local window_entity = window.internal_entity_id
|
||||
local entities_match =
|
||||
(is_nil(limit_entity) and is_nil(window_entity))
|
||||
or limit_entity == window_entity
|
||||
if entities_match then
|
||||
return window
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Returns { [feature_id] = { balance_key, windows, dirty, limit,
|
||||
-- dimension_type, headroom, consumed } }, one entry per distinct capped
|
||||
-- feature in usage_window_limits.
|
||||
local function read_usage_windows(params)
|
||||
local limits = params.usage_window_limits or {}
|
||||
local balance_keys_by_feature_id =
|
||||
safe_table(params.balance_keys_by_feature_id)
|
||||
local usage_windows = {}
|
||||
|
||||
for _, limit in ipairs(limits) do
|
||||
local feature_id = limit.feature_id
|
||||
if usage_windows[feature_id] == nil then
|
||||
local balance_key = balance_keys_by_feature_id[feature_id]
|
||||
local windows = nil
|
||||
|
||||
if not is_nil(balance_key) then
|
||||
local raw_value = redis.call('HGET', balance_key, USAGE_WINDOWS_FIELD)
|
||||
windows = safe_decode(raw_value)
|
||||
end
|
||||
|
||||
if type(windows) ~= 'table' then
|
||||
windows = new_empty_array()
|
||||
end
|
||||
|
||||
-- cjson decodes an empty JSON object ({}) to the same empty table as [];
|
||||
-- a non-empty map-like blob should be impossible for this field, but
|
||||
-- reset it defensively rather than letting ipairs skip rows silently.
|
||||
if next(windows) ~= nil and windows[1] == nil then
|
||||
windows = new_empty_array()
|
||||
end
|
||||
|
||||
local existing = find_usage_window(windows, limit)
|
||||
-- A count is valid only within its exact stamped window: derive 0 when
|
||||
-- it expired OR its bounds no longer match the current derivation (the
|
||||
-- lazy roll persists the zero; this read must not trust it blindly).
|
||||
local current_usage = 0
|
||||
if not is_nil(existing)
|
||||
and safe_number(existing.window_end_at) > safe_number(params.now)
|
||||
and safe_number(existing.window_start_at) == limit.window_start_at
|
||||
then
|
||||
current_usage = safe_number(existing.usage)
|
||||
end
|
||||
local headroom = safe_number(limit.limit) - current_usage
|
||||
if headroom < 0 then
|
||||
headroom = 0
|
||||
end
|
||||
|
||||
usage_windows[feature_id] = {
|
||||
balance_key = not is_nil(balance_key) and balance_key or nil,
|
||||
windows = windows,
|
||||
dirty = false,
|
||||
limit = limit,
|
||||
dimension_type = limit.dimension_type,
|
||||
headroom = headroom,
|
||||
consumed = 0,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return usage_windows
|
||||
end
|
||||
@@ -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