Two things that were only ever confusing, and one of them stopped a command working on the cluster. The store type was named SQLite and kept the name when PostgreSQL was added, so OpenPostgres returned a *SQLite and all seventy-odd methods on the production store were spelled after the database it was not running on. Nothing broke; people did. Earlier today I added three methods to *SQLite for a table that on this cluster only ever lives in PostgreSQL, and the name did not once look wrong. It is a field, which is all it ever was. `llmgw users` and `llmgw keys` defaulted --store to a SQLite path. On a deployment whose store is PostgreSQL every one of them failed with "unable to open database file" while the right answer sat in auth.store in the config file beside them; the conclusion an operator draws is that the tool is broken, and they are not far off. They now ask the server's own configuration before falling back, and the error names which of the three sources chose the address — the failure was never the path, it was which one picked it. The single-machine default still applies last, so a deployment with no config file keeps working with no flags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
64 lines
2.3 KiB
Go
64 lines
2.3 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
// The pure-Go PostgreSQL driver. Pure-Go is not a preference here: the
|
|
// whole binary is built with CGO_ENABLED=0 so that 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.
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
// OpenPostgres opens the store against PostgreSQL.
|
|
//
|
|
// The same queries run here as on SQLite; only the placeholders differ, and
|
|
// the dialect handles that. What differs operationally is concurrency: SQLite
|
|
// is held to one connection because more would only produce lock contention
|
|
// that reads as a deadlock, while PostgreSQL is the reason someone moves to
|
|
// it and wants a pool.
|
|
//
|
|
// The DSN carries a password, so it is taken as a string the caller read from
|
|
// somewhere protected rather than as a path this package knows how to find —
|
|
// and it is never logged, which is why errors below name the database and not
|
|
// the DSN.
|
|
func OpenPostgres(ctx context.Context, dsn string) (*DB, error) {
|
|
if dsn == "" {
|
|
return nil, fmt.Errorf("store: no PostgreSQL address configured")
|
|
}
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
// Deliberately not wrapped with the DSN: it holds a password, and an
|
|
// error string ends up in logs, tickets and screenshots.
|
|
return nil, fmt.Errorf("opening the PostgreSQL store: %w", err)
|
|
}
|
|
|
|
// Sized for a control plane rather than for a fleet of application
|
|
// servers. The request path barely touches the store — usage is batched,
|
|
// budgets are counted in memory, authentication is cached — so the pool
|
|
// exists for conversation turns and reporting queries, not for inference.
|
|
db.SetMaxOpenConns(16)
|
|
db.SetMaxIdleConns(4)
|
|
// Long enough that a quiet cluster keeps its connections, short enough
|
|
// that a restarted database is not answered with dead ones for an hour.
|
|
db.SetConnMaxLifetime(30 * time.Minute)
|
|
db.SetConnMaxIdleTime(5 * time.Minute)
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("reaching the PostgreSQL store: %w", err)
|
|
}
|
|
if err := migrate(ctx, db, string(PostgresDialect)); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
return &DB{
|
|
raw: db,
|
|
dialect: PostgresDialect,
|
|
db: bound{c: PostgresDialect, to: db},
|
|
}, nil
|
|
}
|