-- ============================================================================ -- 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