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:
@@ -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 {
|
||||||
|
|||||||
3
internal/storage/migrations/005_add_query_indexes.sql
Normal file
3
internal/storage/migrations/005_add_query_indexes.sql
Normal 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);
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user