Class: Insika::BudgetLedger

Inherits:
Object
  • Object
show all
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

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

Returns:

  • (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