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 on the hot path AND swept: each add deletes the (id)'s previous day and previous month cell (an ACTIVE scope holds at most 4 keys), and prune — the daily sweep on the tick — drops everything else: the cells of an id that went idle for more than one window and the alert flags, which the hot path never collects.

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.



31
32
33
# File 'lib/insika/budget_ledger.rb', line 31

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.



37
38
39
40
41
42
43
44
45
46
# File 'lib/insika/budget_ledger.rb', line 37

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)


88
89
90
# File 'lib/insika/budget_ledger.rb', line 88

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).



50
51
52
53
54
# File 'lib/insika/budget_ledger.rb', line 50

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?



74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/insika/budget_ledger.rb', line 74

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

#prune(now: Time.now) ⇒ Object

The GC of both scopes: drops every cell whose window is not the CURRENT one. add's two deletes only reach the IMMEDIATELY previous day/month, so a scope that goes idle for two days leaves its counter behind forever, and the alert flags were never collected at all — unbounded row growth the WS8 retention sweep does not reach (that one is age-based over CONTENT; these are counters with no timestamp). Every key of both scopes ENDS in its bucket, so one rule sweeps both. -> count of cells removed.



99
100
101
102
103
104
105
106
107
108
109
# File 'lib/insika/budget_ledger.rb', line 99

def prune(now: Time.now)
  day = daily_bucket(now)
  month = month_bucket(now)
  @store.transaction do
    [SCOPE, ALERT_SCOPE].sum do |scope|
      stale = @store.list(scope).select { |k| past?(k.rpartition(":").last.to_i, day, month) }
      stale.each { |k| @store.delete(scope, k) }
      stale.size
    end
  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.



60
61
62
63
64
65
# File 'lib/insika/budget_ledger.rb', line 60

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