mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-05 12:23:05 +00:00
Atomic budget store for prompt-handler LLM spend, with PG and SQLite implementations. Enforces per-hook token caps to prevent runaway cost from matcher-bypass scenarios.
129 lines
3.5 KiB
Go
129 lines
3.5 KiB
Go
//go:build sqlite || sqliteonly
|
|
|
|
package sqlitestore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/hooks/budget"
|
|
)
|
|
|
|
// SqliteHookBudget implements budget.Dialect over SQLite.
|
|
// SQLite lacks RETURNING on UPDATE in all historical versions; we achieve the
|
|
// same atomicity via a single transaction: UPDATE WHERE remaining>=cost,
|
|
// then SELECT if RowsAffected=1, else UPSERT seed + retry.
|
|
type SqliteHookBudget struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewSQLiteHookBudget returns a SqliteHookBudget over db.
|
|
func NewSQLiteHookBudget(db *sql.DB) *SqliteHookBudget {
|
|
return &SqliteHookBudget{db: db}
|
|
}
|
|
|
|
// DeductAtomic implements budget.Dialect. See PG equivalent for semantics.
|
|
func (b *SqliteHookBudget) DeductAtomic(
|
|
ctx context.Context, tenantID uuid.UUID, cost int64, month time.Time, defaultBudget int64,
|
|
) (int64, int64, error) {
|
|
monthStr := month.Format("2006-01-02")
|
|
|
|
tx, err := b.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("budget begin: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
remaining, total, ok, err := b.tryDeductTx(ctx, tx, tenantID, cost, monthStr)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if ok {
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, 0, fmt.Errorf("budget commit: %w", err)
|
|
}
|
|
return remaining, total, nil
|
|
}
|
|
|
|
if err := b.seedIfStaleTx(ctx, tx, tenantID, monthStr, defaultBudget); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
remaining, total, ok, err = b.tryDeductTx(ctx, tx, tenantID, cost, monthStr)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if !ok {
|
|
_ = tx.Rollback()
|
|
return 0, 0, budget.ErrBudgetExceeded
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, 0, fmt.Errorf("budget commit: %w", err)
|
|
}
|
|
return remaining, total, nil
|
|
}
|
|
|
|
func (b *SqliteHookBudget) tryDeductTx(
|
|
ctx context.Context, tx *sql.Tx, tenantID uuid.UUID, cost int64, monthStr string,
|
|
) (int64, int64, bool, error) {
|
|
nowStr := time.Now().UTC().Format(time.RFC3339Nano)
|
|
res, err := tx.ExecContext(ctx, `
|
|
UPDATE tenant_hook_budget
|
|
SET remaining = remaining - ?,
|
|
updated_at = ?
|
|
WHERE tenant_id = ?
|
|
AND month_start = ?
|
|
AND remaining >= ?`,
|
|
cost, nowStr, tenantID.String(), monthStr, cost,
|
|
)
|
|
if err != nil {
|
|
return 0, 0, false, fmt.Errorf("budget deduct: %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return 0, 0, false, nil
|
|
}
|
|
|
|
var remaining, total int64
|
|
err = tx.QueryRowContext(ctx, `
|
|
SELECT remaining, budget_total
|
|
FROM tenant_hook_budget
|
|
WHERE tenant_id = ? AND month_start = ?`,
|
|
tenantID.String(), monthStr,
|
|
).Scan(&remaining, &total)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, 0, false, nil
|
|
}
|
|
return 0, 0, false, fmt.Errorf("budget read-back: %w", err)
|
|
}
|
|
return remaining, total, true, nil
|
|
}
|
|
|
|
func (b *SqliteHookBudget) seedIfStaleTx(
|
|
ctx context.Context, tx *sql.Tx, tenantID uuid.UUID, monthStr string, defaultBudget int64,
|
|
) error {
|
|
nowStr := time.Now().UTC().Format(time.RFC3339Nano)
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO tenant_hook_budget
|
|
(tenant_id, month_start, budget_total, remaining, metadata, updated_at)
|
|
VALUES (?, ?, ?, ?, '{}', ?)
|
|
ON CONFLICT(tenant_id) DO UPDATE
|
|
SET month_start = excluded.month_start,
|
|
budget_total = excluded.budget_total,
|
|
remaining = excluded.remaining,
|
|
updated_at = ?
|
|
WHERE tenant_hook_budget.month_start < excluded.month_start`,
|
|
tenantID.String(), monthStr, defaultBudget, defaultBudget, nowStr, nowStr,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("budget seed: %w", err)
|
|
}
|
|
return nil
|
|
}
|