Files
synapbus/internal/auth/session_store.go
Algis DumbrisandClaude Opus 4.6 4546bef955 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>
2026-04-14 21:15:32 +03:00

170 lines
5.6 KiB
Go

package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"time"
)
// SessionStore defines the storage interface for session operations.
type SessionStore interface {
CreateSession(ctx context.Context, userID int64, lifetime time.Duration) (*Session, error)
GetSession(ctx context.Context, sessionID string) (*Session, error)
DeleteSession(ctx context.Context, sessionID string) error
DeleteSessionsByUser(ctx context.Context, userID int64) error
DeleteSessionsByUserExcept(ctx context.Context, userID int64, exceptSessionID string) error
CleanupExpired(ctx context.Context) (int64, error)
}
// 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
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, 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.
func (s *SQLiteSessionStore) CreateSession(ctx context.Context, userID int64, lifetime time.Duration) (*Session, error) {
sessionID, err := generateSessionID()
if err != nil {
return nil, fmt.Errorf("generate session id: %w", err)
}
now := time.Now()
expiresAt := now.Add(lifetime)
_, err = s.db.ExecContext(ctx,
`INSERT INTO sessions (session_id, user_id, created_at, expires_at, last_active_at)
VALUES (?, ?, ?, ?, ?)`,
sessionID, userID, now, expiresAt, now,
)
if err != nil {
return nil, fmt.Errorf("insert session: %w", err)
}
return &Session{
SessionID: sessionID,
UserID: userID,
CreatedAt: now,
ExpiresAt: expiresAt,
LastActiveAt: now,
}, nil
}
// 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.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,
&session.ExpiresAt, &session.LastActiveAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrSessionNotFound
}
return nil, fmt.Errorf("query session: %w", err)
}
if time.Now().After(session.ExpiresAt) {
// 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 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
}
// DeleteSession removes a session.
func (s *SQLiteSessionStore) DeleteSession(ctx context.Context, sessionID string) error {
_, err := s.db.ExecContext(ctx,
"DELETE FROM sessions WHERE session_id = ?", sessionID,
)
return err
}
// DeleteSessionsByUser removes all sessions for a user.
func (s *SQLiteSessionStore) DeleteSessionsByUser(ctx context.Context, userID int64) error {
_, err := s.db.ExecContext(ctx,
"DELETE FROM sessions WHERE user_id = ?", userID,
)
return err
}
// DeleteSessionsByUserExcept removes all sessions for a user except the specified one.
func (s *SQLiteSessionStore) DeleteSessionsByUserExcept(ctx context.Context, userID int64, exceptSessionID string) error {
_, err := s.db.ExecContext(ctx,
"DELETE FROM sessions WHERE user_id = ? AND session_id != ?", userID, exceptSessionID,
)
return err
}
// CleanupExpired removes all expired sessions and returns the count removed.
func (s *SQLiteSessionStore) CleanupExpired(ctx context.Context) (int64, error) {
result, err := s.db.ExecContext(ctx,
"DELETE FROM sessions WHERE expires_at < ?", time.Now(),
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// generateSessionID creates a cryptographically random session identifier.
func generateSessionID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}