Class: Insika::BudgetLedger
- Inherits:
-
Object
- Object
- Insika::BudgetLedger
- Defined in:
- lib/insika/budget_ledger.rb
Overview
Fixed-window spend counters for BUDGETS (WS2): the accounting layer the edge middleware rolls against. One scope, cells keyed "tenant:agent:window:calendar-bucket":
· DAILY — the UTC calendar day (epoch/86400 IS midnight-aligned).
· MONTHLY — (year * 12 + month) of the UTC calendar month: a budget month
is the CALENDAR month, however many days long it is (a fixed
N-day window drifts its bucket start across month lengths).
UTC like the daily, so both rollovers agree on any host.
Built on the UsageLedger vocabulary (tenant/agent instead of kind/id) but
on the store directly, with the increment riding @store.transaction — the
exact read-modify-write discipline WS2's enforcement will build on. Two
processes (or two SQLite handles) racing the same cell serialize on
BEGIN IMMEDIATE: no lost update. No enforcement here — the middleware is
WS2; this file is only correct accounting.
Growth is bounded like UsageLedger: each add deletes the (id)'s previous
day AND previous month cell, so an active scope holds at most 4 keys and an
idle one converges to 2.
Constant Summary collapse
- SCOPE =
"budget_counters"- ALERT_SCOPE =
"budget_alerts"- DAY =
86_400
Instance Method Summary collapse
-
#add(tenant:, agent:, by:, now: Time.now) ⇒ Object
Adds
byacross both windows; -> { daily:, monthly: } the NEW totals for (tenant, agent). - #alerted?(tenant:, agent:, window:, level: nil, now: Time.now) ⇒ Boolean
-
#current(tenant:, agent:, now: Time.now) ⇒ Object
-> { daily:, monthly: } current totals for (tenant, agent).
-
#initialize(store:) ⇒ BudgetLedger
constructor
A new instance of BudgetLedger.
-
#mark_alert(tenant:, agent:, window:, level: nil, now: Time.now) ⇒ Object
"1× per window" alert markers (the soft enforcement's event): a flag per (id, window, level, bucket) so a budget that stays over the threshold cannot spam one event per turn.
-
#reset_in(window, now: Time.now) ⇒ Object
Seconds until the window's bucket rolls over (the retry_after the enforcement quotes when a hard budget refuses a turn).
Constructor Details
#initialize(store:) ⇒ BudgetLedger
Returns a new instance of BudgetLedger.
29 30 31 |
# File 'lib/insika/budget_ledger.rb', line 29 def initialize(store:) @store = store end |
Instance Method Details
#add(tenant:, agent:, by:, now: Time.now) ⇒ Object
Adds by across both windows; -> { daily:, monthly: } the NEW totals
for (tenant, agent). Atomic per call: one transaction, both bumps.
35 36 37 38 39 40 41 42 43 44 |
# File 'lib/insika/budget_ledger.rb', line 35 def add(tenant:, agent:, by:, now: Time.now) id = cell_id(tenant, agent) @store.transaction do daily = bump(id, daily_bucket(now), by) monthly = bump(id, month_bucket(now), by) @store.delete(SCOPE, key(id, daily_bucket(now - DAY))) # previous day cell @store.delete(SCOPE, key(id, month_bucket(now) - 1)) # previous calendar month cell { daily: daily, monthly: monthly } end end |
#alerted?(tenant:, agent:, window:, level: nil, now: Time.now) ⇒ Boolean
86 87 88 |
# File 'lib/insika/budget_ledger.rb', line 86 def alerted?(tenant:, agent:, window:, level: nil, now: Time.now) !@store.get(ALERT_SCOPE, alert_key(cell_id(tenant, agent), window, now, level)).nil? end |
#current(tenant:, agent:, now: Time.now) ⇒ Object
-> { daily:, monthly: } current totals for (tenant, agent). Purely read; an expired window reads as 0 (rolls over at the boundary).
48 49 50 51 52 |
# File 'lib/insika/budget_ledger.rb', line 48 def current(tenant:, agent:, now: Time.now) id = cell_id(tenant, agent) { daily: @store.get(SCOPE, key(id, daily_bucket(now))).to_i, monthly: @store.get(SCOPE, key(id, month_bucket(now))).to_i } end |
#mark_alert(tenant:, agent:, window:, level: nil, now: Time.now) ⇒ Object
"1× per window" alert markers (the soft enforcement's event): a flag per
(id, window, level, bucket) so a budget that stays over the threshold
cannot spam one event per turn. level: separates DISTINCT triggers in
the same window (WS2): the alert_at crossing and the real soft-cap
crossing each warn once — the cap event must not be swallowed by the
80% marker having fired earlier. Marked/read in the same transaction
discipline. -> bool: had the window already been marked?
72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/insika/budget_ledger.rb', line 72 def mark_alert(tenant:, agent:, window:, level: nil, now: Time.now) id = cell_id(tenant, agent) flag = alert_key(id, window, now, level) @store.transaction do # `next`, NOT `return`: a non-local return from inside the block skips # the store's COMMIT and leaks the BEGIN IMMEDIATE open — the 2nd turn # over a threshold then locks the whole backend (WS2). next true unless @store.get(ALERT_SCOPE, flag).nil? @store.set(ALERT_SCOPE, flag, 1) false end end |
#reset_in(window, now: Time.now) ⇒ Object
Seconds until the window's bucket rolls over (the retry_after the enforcement quotes when a hard budget refuses a turn). Both windows are UTC-aligned (the daily via the epoch, the monthly via UTC components) so a non-UTC host never quotes a negative or local-midnight reset.
58 59 60 61 62 63 |
# File 'lib/insika/budget_ledger.rb', line 58 def reset_in(window, now: Time.now) case window when :daily then DAY - (now.to_i % DAY) when :monthly then (next_utc_month_start(now) - now).to_i end end |