diff --git a/cmd/synapbus/main.go b/cmd/synapbus/main.go index ec2b592..94aa864 100644 --- a/cmd/synapbus/main.go +++ b/cmd/synapbus/main.go @@ -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) diff --git a/internal/auth/session_store.go b/internal/auth/session_store.go index de69c64..66a9ace 100644 --- a/internal/auth/session_store.go +++ b/internal/auth/session_store.go @@ -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 } diff --git a/internal/auth/user_store.go b/internal/auth/user_store.go index f9d3a6d..6cf10a3 100644 --- a/internal/auth/user_store.go +++ b/internal/auth/user_store.go @@ -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,