178 lines
6.1 KiB
Go
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()
|
|
}
|