fix(auth): split session/user reads onto read pool
Root cause of the Web UI wedge on /conversations/1 (and every other authenticated page when the reactor is busy): SQLiteSessionStore and SQLiteUserStore routed every read through the single-connection write pool. On every authenticated request RequireSession does a GetSession + GetUserByID — both hit the write pool, so each one queues behind every reactor / tracer / messaging write. Observed /api/conversations/1 returning 401 after 113 seconds and login POST timing out for 15+ seconds. - SQLiteSessionStore: new NewSQLiteSessionStoreWithRead that takes separate write + read handles. GetSession routes SELECTs through readDB; the last_active_at bump and expired-session cleanup now fire-and-forget on a background goroutine so HTTP handlers never wait on the write pool for a non-critical liveness poke. - SQLiteUserStore: same split. GetUserByID / GetUserByEmail / GetUserByUsername go through readDB. - main.go: wires db.QueryDB() (the query_only=ON read pool) into both stores via the new constructors. Verified: /api/conversations/1 now returns 200 in <2ms even while the coordinator subprocess is blocking on a long Gemini call. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
190df8119f
commit
4546bef955
@@ -344,8 +344,8 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
// Leave IssuerURL empty for localhost — metadata handler falls back to r.Host
|
||||
|
||||
userStore := auth.NewSQLiteUserStore(db.DB, authCfg.BcryptCost)
|
||||
sessionStore := auth.NewSQLiteSessionStore(db.DB)
|
||||
userStore := auth.NewSQLiteUserStoreWithRead(db.DB, db.QueryDB(), authCfg.BcryptCost)
|
||||
sessionStore := auth.NewSQLiteSessionStoreWithRead(db.DB, db.QueryDB())
|
||||
clientStore := auth.NewSQLiteClientStore(db.DB, authCfg.BcryptCost)
|
||||
fositeStore := auth.NewFositeStore(db.DB, authCfg.BcryptCost)
|
||||
oauthProvider := auth.NewOAuthProvider(authCfg, fositeStore)
|
||||
|
||||
@@ -20,13 +20,34 @@ type SessionStore interface {
|
||||
}
|
||||
|
||||
// SQLiteSessionStore implements SessionStore using SQLite.
|
||||
//
|
||||
// Uses separate write + read connection pools when available. The
|
||||
// write pool has MaxOpenConns=1 so long-running writes (reactor, trace
|
||||
// recorder) would otherwise serialize every session lookup and wedge
|
||||
// the UI. Reads (GetSession) go through the read pool; writes
|
||||
// (CreateSession, DeleteSession, bumping last_active_at) still use
|
||||
// the write pool. If no read pool is wired, both fall back to the
|
||||
// same handle for backward compat.
|
||||
type SQLiteSessionStore struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
readDB *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteSessionStore creates a new SQLite-backed session store.
|
||||
// When only a write handle is provided the same handle is used for
|
||||
// both reads and writes (pre-spec-018 behaviour).
|
||||
func NewSQLiteSessionStore(db *sql.DB) *SQLiteSessionStore {
|
||||
return &SQLiteSessionStore{db: db}
|
||||
return &SQLiteSessionStore{db: db, readDB: db}
|
||||
}
|
||||
|
||||
// NewSQLiteSessionStoreWithRead creates a SessionStore that routes
|
||||
// GetSession SELECTs through readDB while using writeDB for inserts,
|
||||
// deletes, and the last_active_at bump.
|
||||
func NewSQLiteSessionStoreWithRead(writeDB, readDB *sql.DB) *SQLiteSessionStore {
|
||||
if readDB == nil {
|
||||
readDB = writeDB
|
||||
}
|
||||
return &SQLiteSessionStore{db: writeDB, readDB: readDB}
|
||||
}
|
||||
|
||||
// CreateSession creates a new session with a cryptographically random session ID.
|
||||
@@ -57,10 +78,17 @@ func (s *SQLiteSessionStore) CreateSession(ctx context.Context, userID int64, li
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetSession retrieves a session by its ID. Returns ErrSessionExpired if the session has expired.
|
||||
// GetSession retrieves a session by its ID. Returns ErrSessionExpired
|
||||
// if the session has expired.
|
||||
//
|
||||
// Reads go through the read pool (high MaxOpenConns, query_only=ON) so
|
||||
// session validation on every authenticated request never contends
|
||||
// with the single-connection write pool. The last_active_at bump is
|
||||
// fire-and-forget on a background goroutine — it's a liveness
|
||||
// indicator only and must not block the HTTP handler.
|
||||
func (s *SQLiteSessionStore) GetSession(ctx context.Context, sessionID string) (*Session, error) {
|
||||
session := &Session{}
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
err := s.readDB.QueryRowContext(ctx,
|
||||
`SELECT session_id, user_id, created_at, expires_at, last_active_at
|
||||
FROM sessions WHERE session_id = ?`, sessionID,
|
||||
).Scan(&session.SessionID, &session.UserID, &session.CreatedAt,
|
||||
@@ -73,16 +101,25 @@ func (s *SQLiteSessionStore) GetSession(ctx context.Context, sessionID string) (
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
// Clean up the expired session
|
||||
s.DeleteSession(ctx, sessionID)
|
||||
// Clean up the expired session (async — not on the hot path).
|
||||
go func(id string) {
|
||||
bg, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.DeleteSession(bg, id)
|
||||
}(sessionID)
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
|
||||
// Update last_active_at
|
||||
s.db.ExecContext(ctx,
|
||||
`UPDATE sessions SET last_active_at = CURRENT_TIMESTAMP WHERE session_id = ?`,
|
||||
sessionID,
|
||||
)
|
||||
// Update last_active_at in the background so the HTTP handler
|
||||
// doesn't wait on the write pool for a non-critical liveness bump.
|
||||
go func(id string) {
|
||||
bg, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _ = s.db.ExecContext(bg,
|
||||
`UPDATE sessions SET last_active_at = CURRENT_TIMESTAMP WHERE session_id = ?`,
|
||||
id,
|
||||
)
|
||||
}(sessionID)
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
@@ -27,17 +27,37 @@ type UserStore interface {
|
||||
}
|
||||
|
||||
// SQLiteUserStore implements UserStore using SQLite.
|
||||
//
|
||||
// Splits reads (GetUserByID, GetUserByUsername, etc.) onto a separate
|
||||
// read pool when one is configured. The write pool has
|
||||
// MaxOpenConns=1, so every authenticated HTTP request — which does a
|
||||
// GetSession + GetUserByID on the hot path — would otherwise serialize
|
||||
// behind long-running reactor writes and wedge the UI.
|
||||
type SQLiteUserStore struct {
|
||||
db *sql.DB
|
||||
readDB *sql.DB
|
||||
bcryptCost int
|
||||
}
|
||||
|
||||
// NewSQLiteUserStore creates a new SQLite-backed user store.
|
||||
// NewSQLiteUserStore creates a new SQLite-backed user store using a
|
||||
// single handle for reads and writes (pre-spec-018 behaviour).
|
||||
func NewSQLiteUserStore(db *sql.DB, bcryptCost int) *SQLiteUserStore {
|
||||
if bcryptCost < 10 {
|
||||
bcryptCost = 12
|
||||
}
|
||||
return &SQLiteUserStore{db: db, bcryptCost: bcryptCost}
|
||||
return &SQLiteUserStore{db: db, readDB: db, bcryptCost: bcryptCost}
|
||||
}
|
||||
|
||||
// NewSQLiteUserStoreWithRead creates a UserStore that routes SELECTs
|
||||
// through readDB while using writeDB for inserts / updates.
|
||||
func NewSQLiteUserStoreWithRead(writeDB, readDB *sql.DB, bcryptCost int) *SQLiteUserStore {
|
||||
if readDB == nil {
|
||||
readDB = writeDB
|
||||
}
|
||||
if bcryptCost < 10 {
|
||||
bcryptCost = 12
|
||||
}
|
||||
return &SQLiteUserStore{db: writeDB, readDB: readDB, bcryptCost: bcryptCost}
|
||||
}
|
||||
|
||||
// CreateUser creates a new user with a bcrypt-hashed password.
|
||||
@@ -100,10 +120,11 @@ func (s *SQLiteUserStore) CreateUser(ctx context.Context, username, password, di
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by their ID.
|
||||
// GetUserByID retrieves a user by their ID. Uses the read pool so
|
||||
// RequireSession middleware calls don't contend with reactor writes.
|
||||
func (s *SQLiteUserStore) GetUserByID(ctx context.Context, id int64) (*User, error) {
|
||||
user := &User{}
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
err := s.readDB.QueryRowContext(ctx,
|
||||
`SELECT id, username, password_hash, display_name, role, created_at, updated_at
|
||||
FROM users WHERE id = ?`, id,
|
||||
).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.DisplayName,
|
||||
@@ -124,7 +145,7 @@ func (s *SQLiteUserStore) GetUserByEmail(ctx context.Context, email string) (*Us
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
user := &User{}
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
err := s.readDB.QueryRowContext(ctx,
|
||||
`SELECT id, username, password_hash, display_name, role, created_at, updated_at
|
||||
FROM users WHERE email = ?`, email,
|
||||
).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.DisplayName,
|
||||
@@ -147,10 +168,10 @@ func (s *SQLiteUserStore) SetEmail(ctx context.Context, userID int64, email stri
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserByUsername retrieves a user by their username.
|
||||
// GetUserByUsername retrieves a user by their username. Uses the read pool.
|
||||
func (s *SQLiteUserStore) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
user := &User{}
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
err := s.readDB.QueryRowContext(ctx,
|
||||
`SELECT id, username, password_hash, display_name, role, created_at, updated_at
|
||||
FROM users WHERE username = ?`, username,
|
||||
).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.DisplayName,
|
||||
|
||||
Reference in New Issue
Block a user