dev-pod-api-build/internal/store/usage.go
2026-04-16 04:16:36 +00:00

178 lines
6.1 KiB
Go

package store
import (
"context"
"fmt"
"time"
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
)
// RecordPodStart records a pod start event for usage tracking.
func (s *Store) RecordPodStart(ctx context.Context, userID, podName string) error {
_, err := s.db.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ($1, $2, $3, 0, NOW())`,
userID, podName, model.EventPodStart)
if err != nil {
return fmt.Errorf("record pod start: %w", err)
}
return nil
}
// RecordPodStop records a pod stop event for usage tracking.
func (s *Store) RecordPodStop(ctx context.Context, userID, podName string) error {
_, err := s.db.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ($1, $2, $3, 0, NOW())`,
userID, podName, model.EventPodStop)
if err != nil {
return fmt.Errorf("record pod stop: %w", err)
}
return nil
}
// RecordResourceSample records periodic CPU and memory usage samples.
// cpuMillicores is CPU usage in millicores, memBytes is memory in bytes.
func (s *Store) RecordResourceSample(ctx context.Context, userID, podName string, cpuMillicores, memBytes float64) error {
now := time.Now().UTC()
_, err := s.db.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ($1, $2, $3, $4, $5), ($1, $2, $6, $7, $5)`,
userID, podName, model.EventCPUSample, cpuMillicores, now,
model.EventMemSample, memBytes)
if err != nil {
return fmt.Errorf("record resource sample: %w", err)
}
return nil
}
// RecordAIRequest records an AI proxy request event.
func (s *Store) RecordAIRequest(ctx context.Context, userID string) error {
_, err := s.db.Exec(ctx,
`INSERT INTO usage_records (user_id, pod_name, event_type, value, recorded_at)
VALUES ($1, '', $2, 1, NOW())`,
userID, model.EventAIRequest)
if err != nil {
return fmt.Errorf("record ai request: %w", err)
}
return nil
}
// GetUsage returns aggregated usage for a user for a given month.
func (s *Store) GetUsage(ctx context.Context, userID string, year int, month time.Month, monthlyBudget int) (*model.UsageSummary, error) {
return s.getUsage(ctx, userID, year, month, monthlyBudget, time.Now().UTC())
}
// getUsage is the internal implementation that accepts a "now" parameter for testing.
func (s *Store) getUsage(ctx context.Context, userID string, year int, month time.Month, monthlyBudget int, now time.Time) (*model.UsageSummary, error) {
monthStart := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
monthEnd := monthStart.AddDate(0, 1, 0)
// Calculate pod-hours from pod_start/pod_stop pairs.
// For each pod_start, find the nearest subsequent pod_stop for the same user/pod.
// If no stop found, use "now" (pod is still running).
// Sessions that cross month boundaries are clamped to [monthStart, monthEnd].
var podHours float64
err := s.db.QueryRow(ctx, `
WITH sessions AS (
SELECT
GREATEST(recorded_at, $2) AS start_time,
(
SELECT MIN(r2.recorded_at)
FROM usage_records r2
WHERE r2.user_id = r.user_id
AND r2.pod_name = r.pod_name
AND r2.event_type = 'pod_stop'
AND r2.recorded_at > r.recorded_at
) AS stop_time
FROM usage_records r
WHERE r.user_id = $1
AND r.event_type = 'pod_start'
AND r.recorded_at < $3
)
SELECT COALESCE(SUM(
EXTRACT(EPOCH FROM LEAST(COALESCE(stop_time, $4), $3) - start_time) / 3600.0
), 0)
FROM sessions
WHERE COALESCE(stop_time, $4) > $2`,
userID, monthStart, monthEnd, now).Scan(&podHours)
if err != nil {
return nil, fmt.Errorf("calculate pod hours: %w", err)
}
// CPU-hours from resource samples.
// Each sample represents 60s of CPU at the sampled rate (millicores).
// CPU-hours = sum(millicores) * 60s / 3600s / 1000m = sum(millicores) / 60000
var cpuHours float64
err = s.db.QueryRow(ctx, `
SELECT COALESCE(SUM(value) / 60000.0, 0)
FROM usage_records
WHERE user_id = $1 AND event_type = $2
AND recorded_at >= $3 AND recorded_at < $4`,
userID, model.EventCPUSample, monthStart, monthEnd).Scan(&cpuHours)
if err != nil {
return nil, fmt.Errorf("calculate cpu hours: %w", err)
}
// Count AI requests.
var aiRequests int64
err = s.db.QueryRow(ctx, `
SELECT COUNT(*)
FROM usage_records
WHERE user_id = $1 AND event_type = $2
AND recorded_at >= $3 AND recorded_at < $4`,
userID, model.EventAIRequest, monthStart, monthEnd).Scan(&aiRequests)
if err != nil {
return nil, fmt.Errorf("count ai requests: %w", err)
}
var budgetUsedPct float64
if monthlyBudget > 0 {
budgetUsedPct = (podHours / float64(monthlyBudget)) * 100
}
return &model.UsageSummary{
PodHours: podHours,
CPUHours: cpuHours,
AIRequests: aiRequests,
BudgetUsedPct: budgetUsedPct,
}, nil
}
// GetDailyUsage returns daily usage breakdown for a user for a given month.
// Pod-hours per day are estimated from CPU sample count (each sample = 1/60 hour of pod time).
func (s *Store) GetDailyUsage(ctx context.Context, userID string, year int, month time.Month) ([]model.DailyUsage, error) {
monthStart := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
monthEnd := monthStart.AddDate(0, 1, 0)
rows, err := s.db.Query(ctx, `
SELECT
DATE(recorded_at) AS day,
COALESCE(SUM(CASE WHEN event_type = 'cpu_sample' THEN 1.0/60.0 ELSE 0 END), 0) AS pod_hours,
COALESCE(SUM(CASE WHEN event_type = 'cpu_sample' THEN value / 60000.0 ELSE 0 END), 0) AS cpu_hours,
COUNT(*) FILTER (WHERE event_type = 'ai_request') AS ai_requests
FROM usage_records
WHERE user_id = $1
AND recorded_at >= $2 AND recorded_at < $3
AND event_type IN ('cpu_sample', 'ai_request')
GROUP BY DATE(recorded_at)
ORDER BY DATE(recorded_at)`,
userID, monthStart, monthEnd)
if err != nil {
return nil, fmt.Errorf("query daily usage: %w", err)
}
defer rows.Close()
var result []model.DailyUsage
for rows.Next() {
var d model.DailyUsage
var day time.Time
if err := rows.Scan(&day, &d.PodHours, &d.CPUHours, &d.AIRequests); err != nil {
return nil, fmt.Errorf("scan daily usage: %w", err)
}
d.Date = day.Format("2006-01-02")
result = append(result, d)
}
return result, rows.Err()
}