A path is still SQLite and a postgres:// URL is PostgreSQL, so every existing configuration keeps working untouched and moving is one line changed rather than a new setting to find. An address of the form @/path/to/file reads the real one out of that file, which is how a DSN carrying a password stays out of a configuration that gets copied, diffed and pasted into issues. The driver is pgx, which is pure Go. That is not a preference: the whole binary is built with CGO_ENABLED=0 so it runs on a node whose libc is older than the builder's, and a cgo driver would take that away from every deployment, including the ones still on SQLite. Tested against a real PostgreSQL, which found two things nothing else could. The schema_migrations table is created in Go rather than in a migration file, so it escaped the port and still said INTEGER — 64 bits in SQLite, 32 in PostgreSQL, and a millisecond timestamp does not fit, so the very first migration failed to record that it had run. And the memory migration uses an FTS5 virtual table with three triggers keeping it in step, which has no counterpart: PostgreSQL derives a tsvector from the row, so there is nothing to keep in step and no trigger to forget. That leaves exactly one query that is not the same sentence in both dialects — the note search, MATCH against @@ — and it is written out twice rather than hidden behind a shared spelling that would mean something subtly different in each. Everything else is one set of queries. The text search uses the 'simple' configuration rather than English. These are an agent's own notes, in whatever language it chose, and stemming English rules over Turkish would quietly stop finding things. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
174 lines
4.9 KiB
Go
174 lines
4.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"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) *SQLite {
|
|
t.Helper()
|
|
dsn := os.Getenv("LLMGW_TEST_POSTGRES")
|
|
if dsn == "" {
|
|
t.Skip("set LLMGW_TEST_POSTGRES to a database DSN to run these")
|
|
}
|
|
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(),
|
|
`TRUNCATE thread_messages, threads, memory_notes, usage, api_keys,
|
|
key_claims, limits, models, sessions, users, projects, teams,
|
|
organizations RESTART IDENTITY CASCADE`)
|
|
if err != nil {
|
|
t.Fatalf("clearing the test database: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|