cleaned up usage windows

This commit is contained in:
johnyeo
2026-06-11 15:28:59 +01:00
parent e7fa94e428
commit db7088b4dd
152 changed files with 9203 additions and 2051 deletions

View File

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

View File

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