CI / test (push) Has been cancelled
Usage could be read as a total — per key, per user, per model, per group — and the cluster's own charts covered the fleet's hardware. What nobody could see was when a group used it. "Research doubled its traffic three weeks ago" is not a question a column of totals can be asked, and it is the question somebody asks before a quota conversation. /admin/usage draws requests, tokens and spend over a chosen window, grouped by any level of the tenancy tree or by person. Person is not a level of that tree — one person holds keys in several projects — so the grouping is its own type rather than store.Scope with a fourth value nothing else could use. Three things learned from the charts already here. Buckets are gap-filled per group, because a line drawn straight across an absence claims traffic that did not happen, and because the chart sums its tail by timestamp and can only do that if every series shares a clock. Series are ranked by total rather than by peak — a counter's question is "who used the most", and ranking by peak would colour the team that made one burst over the team that ran steadily all week. And the spend chart is replaced by a sentence when no model has a price, rather than drawn flat along the bottom. The bucket query groups by ordinal and was planned against the cluster's PostgreSQL before being believed, with a test alongside the others that run there: three reports have shipped working on SQLite and failing on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
255 lines
7.8 KiB
Go
255 lines
7.8 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// openPG opens the test database named by LLMGW_TEST_POSTGRES, or skips.
|
|
//
|
|
// Env-gated rather than spun up here, because a store test that needs a
|
|
// container is a test nobody runs. Pointed at a database, it runs the same
|
|
// assertions the SQLite tests make — which is the only thing that can catch
|
|
// the two backends drifting apart.
|
|
//
|
|
// Every table is emptied first. The alternative is a test database that
|
|
// accumulates rows until an assertion about counts starts failing for reasons
|
|
// that have nothing to do with the change being tested.
|
|
func openPG(t *testing.T) *DB {
|
|
t.Helper()
|
|
dsn := os.Getenv("LLMGW_TEST_POSTGRES")
|
|
if dsn == "" {
|
|
t.Skip("set LLMGW_TEST_POSTGRES to a database DSN to run these")
|
|
}
|
|
requireTestDatabase(t, dsn)
|
|
|
|
s, err := OpenPostgres(context.Background(), dsn)
|
|
if err != nil {
|
|
t.Fatalf("opening the test database: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
|
|
// CASCADE because the tables reference each other, and TRUNCATE rather
|
|
// than DELETE so identity columns restart too.
|
|
_, err = s.db.ExecContext(context.Background(),
|
|
// leadership included, and it is not in copyOrder: a subtest that
|
|
// inherits the previous one's lease is refused for reasons that have
|
|
// nothing to do with what it is testing. It passed on SQLite, where
|
|
// every subtest gets a fresh file, and failed only here.
|
|
`TRUNCATE thread_messages, threads, memory_notes, usage, api_keys,
|
|
key_claims, limits, models, sessions, users, projects, teams,
|
|
organizations, leadership RESTART IDENTITY CASCADE`)
|
|
if err != nil {
|
|
t.Fatalf("clearing the test database: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// requireTestDatabase refuses to run against a database that is not obviously
|
|
// a test one.
|
|
//
|
|
// These tests truncate every table before each one. Pointed at a real
|
|
// deployment that is a silent, complete data loss — every key, every user,
|
|
// every conversation — and it is not a hypothetical: it happened here, through
|
|
// an SSH tunnel to the production database that had been opened minutes
|
|
// earlier for a perfectly good reason. The DSN was correct for what it was
|
|
// opened for and catastrophic for what it was then used for.
|
|
//
|
|
// The name has to say so. A guard that could be satisfied by accident would
|
|
// not have stopped it.
|
|
func requireTestDatabase(t *testing.T, dsn string) {
|
|
t.Helper()
|
|
u, err := url.Parse(dsn)
|
|
if err != nil {
|
|
t.Fatalf("LLMGW_TEST_POSTGRES is not a URL: %v", err)
|
|
}
|
|
name := strings.TrimPrefix(u.Path, "/")
|
|
if !strings.Contains(strings.ToLower(name), "test") {
|
|
t.Fatalf("refusing to run destructive tests against database %q:\n"+
|
|
" these TRUNCATE every table before each test.\n"+
|
|
" Point LLMGW_TEST_POSTGRES at a database whose name contains \"test\".", name)
|
|
}
|
|
}
|
|
|
|
func TestPostgresCarriesTheWholeTenancyTree(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := openPG(t)
|
|
|
|
proj := tree(t, s)
|
|
secret, k := mustKey(t, s, "k1", proj)
|
|
_ = secret
|
|
|
|
got, err := s.KeyByHash(ctx, k.Hash)
|
|
if err != nil {
|
|
t.Fatalf("reading the key back: %v", err)
|
|
}
|
|
if got.ID != k.ID || got.ProjectID != proj {
|
|
t.Errorf("key came back as %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestPostgresKeepsUsageAndSpend(t *testing.T) {
|
|
// The accounting path, which is the one that decides whether a budget
|
|
// means anything after a restart.
|
|
ctx := context.Background()
|
|
s := openPG(t)
|
|
proj := tree(t, s)
|
|
_, k := mustKey(t, s, "k1", proj)
|
|
|
|
now := time.Now()
|
|
err := s.WriteUsage(ctx, []Usage{
|
|
{At: now, KeyID: k.ID, ProjectID: proj, Model: "m", ServedModel: "m",
|
|
PromptTokens: 10, CompletionTokens: 20, CostMicros: 500, Status: 200, LatencyMs: 42},
|
|
{At: now, KeyID: k.ID, ProjectID: proj, Model: "m", ServedModel: "m",
|
|
PromptTokens: 1, CompletionTokens: 2, CostMicros: 250, Status: 200, LatencyMs: 7},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("writing usage: %v", err)
|
|
}
|
|
|
|
spend, err := s.SpendSince(ctx, now.Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if spend[k.ID] != 750 {
|
|
t.Errorf("spend = %d, want 750", spend[k.ID])
|
|
}
|
|
}
|
|
|
|
func TestPostgresKeepsThreadsInOrderAndScoped(t *testing.T) {
|
|
// The newest tables, and the ones with the most SQL per operation —
|
|
// a transaction, a MAX(seq), an insert per turn.
|
|
ctx := context.Background()
|
|
s := openPG(t)
|
|
proj := tree(t, s)
|
|
if err := s.SetThreadRetention(ctx, proj, 24); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, m := range []Message{
|
|
{Role: "user", Content: "first"},
|
|
{Role: "assistant", Content: "answer", Model: "small"},
|
|
{Role: "user", Content: "second"},
|
|
} {
|
|
if err := s.AppendThread(ctx, proj, "th1", []Message{m}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
got, err := s.Thread(ctx, proj, "th1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 3 || got[0].Content != "first" || got[2].Content != "second" {
|
|
t.Fatalf("thread came back as %+v", got)
|
|
}
|
|
if got[1].Model != "small" {
|
|
t.Errorf("the model that answered was lost: %+v", got[1])
|
|
}
|
|
|
|
// And the scope holds here exactly as it does on SQLite.
|
|
if err := s.PutProject(ctx, Project{ID: "proj2", TeamID: "team1", Name: "Other"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.Thread(ctx, "proj2", "th1"); !errors.Is(err, ErrNoThread) {
|
|
t.Errorf("reading across projects returned %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPostgresEnforcesLimitsTheSameWay(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := openPG(t)
|
|
proj := tree(t, s)
|
|
_, k := mustKey(t, s, "k1", proj)
|
|
|
|
rpm := 7
|
|
budget := int64(9000)
|
|
if err := s.PutLimits(ctx, Limits{
|
|
Scope: ScopeProject, ScopeID: proj, RPM: &rpm,
|
|
Budget: &budget, BudgetPeriod: PeriodMonth,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
eff, err := s.Resolve(ctx, k.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if eff.RPM == nil || *eff.RPM != 7 {
|
|
t.Errorf("rpm did not resolve down the tree: %+v", eff.RPM)
|
|
}
|
|
if eff.Budget == nil || *eff.Budget != 9000 {
|
|
t.Errorf("budget did not resolve down the tree: %+v", eff.Budget)
|
|
}
|
|
}
|
|
|
|
func TestPostgresUpsertsRatherThanDuplicating(t *testing.T) {
|
|
// ON CONFLICT is the one non-trivial construct shared by both dialects.
|
|
// If it behaved differently here, applying a configuration twice would
|
|
// start failing instead of being a no-op.
|
|
ctx := context.Background()
|
|
s := openPG(t)
|
|
tree(t, s)
|
|
tree(t, s)
|
|
|
|
orgs, err := s.Orgs(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(orgs) != 1 {
|
|
t.Errorf("applying the tree twice produced %d orgs", len(orgs))
|
|
}
|
|
}
|
|
|
|
func TestPostgresBucketsUsageByEveryGrouping(t *testing.T) {
|
|
// The query that most deserves this. It selects a computed bucket
|
|
// expression and groups by ordinal, and three earlier reports shipped
|
|
// working on SQLite and failing here because PostgreSQL insists every
|
|
// non-aggregated column is grouped and matches grouped expressions to
|
|
// selected ones by their text.
|
|
ctx := context.Background()
|
|
s := openPG(t)
|
|
proj := tree(t, s)
|
|
_, k := mustKey(t, s, "k1", proj)
|
|
|
|
now := time.Now()
|
|
err := s.WriteUsage(ctx, []Usage{
|
|
{At: now.Add(-2 * time.Hour), KeyID: k.ID, ProjectID: proj,
|
|
Model: "m", ServedModel: "m",
|
|
PromptTokens: 100, CompletionTokens: 50, CostMicros: 900, Status: 200},
|
|
{At: now, KeyID: k.ID, ProjectID: proj, Model: "m", ServedModel: "m",
|
|
PromptTokens: 10, CompletionTokens: 5, CostMicros: 100, Status: 500},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("writing usage: %v", err)
|
|
}
|
|
|
|
for _, g := range Groupings() {
|
|
rows, err := s.UsageBucketsBy(ctx, g, now.Add(-6*time.Hour), time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("grouping by %s: %v", g, err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("grouping by %s returned %d groups, want 1", g, len(rows))
|
|
}
|
|
if rows[0].CostMicros != 1000 || rows[0].Requests != 2 || rows[0].Errors != 1 {
|
|
t.Errorf("grouping by %s: %d micros, %d requests, %d errors",
|
|
g, rows[0].CostMicros, rows[0].Requests, rows[0].Errors)
|
|
}
|
|
busy := 0
|
|
for _, b := range rows[0].Buckets {
|
|
if b.Requests > 0 {
|
|
busy++
|
|
}
|
|
}
|
|
if busy != 2 {
|
|
t.Errorf("grouping by %s put the traffic in %d buckets, want 2", g, busy)
|
|
}
|
|
}
|
|
}
|