fix: prevent context canceled errors in web dashboard

Detach DB queries from HTTP request context so HTMX polling doesn't
cancel in-flight queries when the browser aborts previous XHRs. Add
indexes on login_attempts and sessions to speed up frequent dashboard
queries. Bump version to 0.17.1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 22:16:49 +01:00
parent 62de222488
commit 9783ae5865
5 changed files with 72 additions and 15 deletions

View File

@@ -20,7 +20,7 @@ import (
"git.t-juice.club/torjus/oubliette/internal/web" "git.t-juice.club/torjus/oubliette/internal/web"
) )
const Version = "0.17.0" const Version = "0.17.1"
func main() { func main() {
if err := run(); err != nil { if err := run(); err != nil {

View File

@@ -0,0 +1,3 @@
CREATE INDEX idx_login_attempts_username ON login_attempts(username);
CREATE INDEX idx_login_attempts_password ON login_attempts(password);
CREATE INDEX idx_sessions_disconnected_at ON sessions(disconnected_at);

View File

@@ -25,8 +25,8 @@ func TestMigrateCreatesTablesAndVersion(t *testing.T) {
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil { if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
t.Fatalf("query version: %v", err) t.Fatalf("query version: %v", err)
} }
if version != 4 { if version != 5 {
t.Errorf("version = %d, want 4", version) t.Errorf("version = %d, want 5", version)
} }
// Verify tables exist by inserting into them. // Verify tables exist by inserting into them.
@@ -64,8 +64,8 @@ func TestMigrateIdempotent(t *testing.T) {
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil { if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
t.Fatalf("query version: %v", err) t.Fatalf("query version: %v", err)
} }
if version != 4 { if version != 5 {
t.Errorf("version = %d after double migrate, want 4", version) t.Errorf("version = %d after double migrate, want 5", version)
} }
} }

View File

@@ -1,6 +1,7 @@
package web package web
import ( import (
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"net/http" "net/http"
@@ -10,6 +11,13 @@ import (
"git.t-juice.club/torjus/oubliette/internal/storage" "git.t-juice.club/torjus/oubliette/internal/storage"
) )
// dbContext returns a context detached from the HTTP request lifecycle with a
// 30-second timeout. This prevents HTMX polling from canceling in-flight DB
// queries when the browser aborts the previous XHR.
func dbContext(r *http.Request) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Second)
}
type dashboardData struct { type dashboardData struct {
Stats *storage.DashboardStats Stats *storage.DashboardStats
TopUsernames []storage.TopEntry TopUsernames []storage.TopEntry
@@ -22,7 +30,8 @@ type dashboardData struct {
} }
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx, cancel := dbContext(r)
defer cancel()
stats, err := s.store.GetDashboardStats(ctx) stats, err := s.store.GetDashboardStats(ctx)
if err != nil { if err != nil {
@@ -98,7 +107,10 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleFragmentStats(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFragmentStats(w http.ResponseWriter, r *http.Request) {
stats, err := s.store.GetDashboardStats(r.Context()) ctx, cancel := dbContext(r)
defer cancel()
stats, err := s.store.GetDashboardStats(ctx)
if err != nil { if err != nil {
s.logger.Error("failed to get dashboard stats", "err", err) s.logger.Error("failed to get dashboard stats", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -112,7 +124,10 @@ func (s *Server) handleFragmentStats(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleFragmentActiveSessions(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFragmentActiveSessions(w http.ResponseWriter, r *http.Request) {
sessions, err := s.store.GetRecentSessions(r.Context(), 50, true) ctx, cancel := dbContext(r)
defer cancel()
sessions, err := s.store.GetRecentSessions(ctx, 50, true)
if err != nil { if err != nil {
s.logger.Error("failed to get active sessions", "err", err) s.logger.Error("failed to get active sessions", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -126,8 +141,11 @@ func (s *Server) handleFragmentActiveSessions(w http.ResponseWriter, r *http.Req
} }
func (s *Server) handleFragmentRecentSessions(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFragmentRecentSessions(w http.ResponseWriter, r *http.Request) {
ctx, cancel := dbContext(r)
defer cancel()
f := parseDashboardFilter(r) f := parseDashboardFilter(r)
sessions, err := s.store.GetFilteredSessions(r.Context(), 50, false, f) sessions, err := s.store.GetFilteredSessions(ctx, 50, false, f)
if err != nil { if err != nil {
s.logger.Error("failed to get filtered sessions", "err", err) s.logger.Error("failed to get filtered sessions", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -147,7 +165,8 @@ type sessionDetailData struct {
} }
func (s *Server) handleSessionDetail(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionDetail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx, cancel := dbContext(r)
defer cancel()
sessionID := r.PathValue("id") sessionID := r.PathValue("id")
session, err := s.store.GetSession(ctx, sessionID) session, err := s.store.GetSession(ctx, sessionID)
@@ -246,7 +265,10 @@ func (s *Server) handleAPIAttemptsOverTime(w http.ResponseWriter, r *http.Reques
since := parseDateParam(r, "since") since := parseDateParam(r, "since")
until := parseDateParam(r, "until") until := parseDateParam(r, "until")
points, err := s.store.GetAttemptsOverTime(r.Context(), days, since, until) ctx, cancel := dbContext(r)
defer cancel()
points, err := s.store.GetAttemptsOverTime(ctx, days, since, until)
if err != nil { if err != nil {
s.logger.Error("failed to get attempts over time", "err", err) s.logger.Error("failed to get attempts over time", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -277,10 +299,13 @@ type apiHourlyPatternResponse struct {
} }
func (s *Server) handleAPIHourlyPattern(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAPIHourlyPattern(w http.ResponseWriter, r *http.Request) {
ctx, cancel := dbContext(r)
defer cancel()
since := parseDateParam(r, "since") since := parseDateParam(r, "since")
until := parseDateParam(r, "until") until := parseDateParam(r, "until")
counts, err := s.store.GetHourlyPattern(r.Context(), since, until) counts, err := s.store.GetHourlyPattern(ctx, since, until)
if err != nil { if err != nil {
s.logger.Error("failed to get hourly pattern", "err", err) s.logger.Error("failed to get hourly pattern", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -308,7 +333,10 @@ type apiCountryStatsResponse struct {
} }
func (s *Server) handleAPICountryStats(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAPICountryStats(w http.ResponseWriter, r *http.Request) {
counts, err := s.store.GetCountryStats(r.Context()) ctx, cancel := dbContext(r)
defer cancel()
counts, err := s.store.GetCountryStats(ctx)
if err != nil { if err != nil {
s.logger.Error("failed to get country stats", "err", err) s.logger.Error("failed to get country stats", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -327,7 +355,8 @@ func (s *Server) handleAPICountryStats(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleFragmentDashboardContent(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFragmentDashboardContent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx, cancel := dbContext(r)
defer cancel()
f := parseDashboardFilter(r) f := parseDashboardFilter(r)
stats, err := s.store.GetFilteredDashboardStats(ctx, f) stats, err := s.store.GetFilteredDashboardStats(ctx, f)
@@ -380,7 +409,8 @@ func (s *Server) handleFragmentDashboardContent(w http.ResponseWriter, r *http.R
} }
func (s *Server) handleAPISessionEvents(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAPISessionEvents(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx, cancel := dbContext(r)
defer cancel()
sessionID := r.PathValue("id") sessionID := r.PathValue("id")
events, err := s.store.GetSessionEvents(ctx, sessionID) events, err := s.store.GetSessionEvents(ctx, sessionID)

View File

@@ -54,6 +54,30 @@ func newSeededTestServer(t *testing.T) *Server {
return srv return srv
} }
func TestDbContextNotCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(ctx)
dbCtx, dbCancel := dbContext(req)
defer dbCancel()
// Cancel the original request context.
cancel()
// The DB context should still be usable.
select {
case <-dbCtx.Done():
t.Fatal("dbContext should not be canceled when request context is canceled")
default:
}
// Verify the DB context has a deadline (from the timeout).
if _, ok := dbCtx.Deadline(); !ok {
t.Error("dbContext should have a deadline")
}
}
func TestDashboardHandler(t *testing.T) { func TestDashboardHandler(t *testing.T) {
t.Run("empty store", func(t *testing.T) { t.Run("empty store", func(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)