From 9783ae58652f11d5d2a7527c4c3524c4c2eaed8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Sat, 7 Mar 2026 22:16:49 +0100 Subject: [PATCH] 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 --- cmd/oubliette/main.go | 2 +- .../migrations/005_add_query_indexes.sql | 3 ++ internal/storage/migrations_test.go | 8 +-- internal/web/handlers.go | 50 +++++++++++++++---- internal/web/web_test.go | 24 +++++++++ 5 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 internal/storage/migrations/005_add_query_indexes.sql diff --git a/cmd/oubliette/main.go b/cmd/oubliette/main.go index 694f0e4..bf191fc 100644 --- a/cmd/oubliette/main.go +++ b/cmd/oubliette/main.go @@ -20,7 +20,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/web" ) -const Version = "0.17.0" +const Version = "0.17.1" func main() { if err := run(); err != nil { diff --git a/internal/storage/migrations/005_add_query_indexes.sql b/internal/storage/migrations/005_add_query_indexes.sql new file mode 100644 index 0000000..0b69e27 --- /dev/null +++ b/internal/storage/migrations/005_add_query_indexes.sql @@ -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); diff --git a/internal/storage/migrations_test.go b/internal/storage/migrations_test.go index 2fe16db..39aacb1 100644 --- a/internal/storage/migrations_test.go +++ b/internal/storage/migrations_test.go @@ -25,8 +25,8 @@ func TestMigrateCreatesTablesAndVersion(t *testing.T) { if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil { t.Fatalf("query version: %v", err) } - if version != 4 { - t.Errorf("version = %d, want 4", version) + if version != 5 { + t.Errorf("version = %d, want 5", version) } // 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 { t.Fatalf("query version: %v", err) } - if version != 4 { - t.Errorf("version = %d after double migrate, want 4", version) + if version != 5 { + t.Errorf("version = %d after double migrate, want 5", version) } } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 3534d82..6fafb46 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -1,6 +1,7 @@ package web import ( + "context" "encoding/base64" "encoding/json" "net/http" @@ -10,6 +11,13 @@ import ( "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 { Stats *storage.DashboardStats TopUsernames []storage.TopEntry @@ -22,7 +30,8 @@ type dashboardData struct { } 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) 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) { - stats, err := s.store.GetDashboardStats(r.Context()) + ctx, cancel := dbContext(r) + defer cancel() + + stats, err := s.store.GetDashboardStats(ctx) if err != nil { s.logger.Error("failed to get dashboard stats", "err", err) 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) { - 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 { s.logger.Error("failed to get active sessions", "err", err) 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) { + ctx, cancel := dbContext(r) + defer cancel() + 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 { s.logger.Error("failed to get filtered sessions", "err", err) 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) { - ctx := r.Context() + ctx, cancel := dbContext(r) + defer cancel() sessionID := r.PathValue("id") 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") 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 { s.logger.Error("failed to get attempts over time", "err", err) 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) { + ctx, cancel := dbContext(r) + defer cancel() + since := parseDateParam(r, "since") until := parseDateParam(r, "until") - counts, err := s.store.GetHourlyPattern(r.Context(), since, until) + counts, err := s.store.GetHourlyPattern(ctx, since, until) if err != nil { s.logger.Error("failed to get hourly pattern", "err", err) 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) { - counts, err := s.store.GetCountryStats(r.Context()) + ctx, cancel := dbContext(r) + defer cancel() + + counts, err := s.store.GetCountryStats(ctx) if err != nil { s.logger.Error("failed to get country stats", "err", err) 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) { - ctx := r.Context() + ctx, cancel := dbContext(r) + defer cancel() f := parseDashboardFilter(r) 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) { - ctx := r.Context() + ctx, cancel := dbContext(r) + defer cancel() sessionID := r.PathValue("id") events, err := s.store.GetSessionEvents(ctx, sessionID) diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 9b8e1db..8e270b4 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -54,6 +54,30 @@ func newSeededTestServer(t *testing.T) *Server { 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) { t.Run("empty store", func(t *testing.T) { srv := newTestServer(t)