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:
Algis Dumbris
2026-04-14 21:15:32 +03:00
co-authored by Claude Opus 4.6
parent 190df8119f
commit 4546bef955
3 changed files with 78 additions and 20 deletions
+2 -2
View File
@@ -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)
+48 -11
View File
@@ -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
}
+28 -7
View File
@@ -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,