diff --git a/client/src/pages/admin/index.tsx b/client/src/pages/admin/index.tsx index 0d97a32..e30e6f3 100644 --- a/client/src/pages/admin/index.tsx +++ b/client/src/pages/admin/index.tsx @@ -3,7 +3,7 @@ import { Helmet } from 'react-helmet'; import { useNavigate } from 'react-router-dom'; import { postRequest } from '../../api'; import { errorAlert } from '../../util'; -import { useAdminStore, useClockStore, useFlagsStore, useOptionsStore } from '../../store'; +import { useAdminStore } from '../../store'; import AdminStatsPanel from '../../components/admin/AdminStatsPanel'; import AdminTable from '../../components/admin/tables/AdminTable'; import AdminToggleSwitch from '../../components/admin/AdminToggleSwitch'; @@ -19,19 +19,14 @@ import JudgesTable from '../../components/admin/tables/JudgesTable'; const Admin = () => { const navigate = useNavigate(); - const fetchStats = useAdminStore((state) => state.fetchStats); - const fetchClock = useClockStore((state) => state.fetchClock); - const fetchProjects = useAdminStore((state) => state.fetchProjects); - const fetchJudges = useAdminStore((state) => state.fetchJudges); - const fetchOptions = useOptionsStore((state) => state.fetchOptions); - const fetchFlags = useFlagsStore((state) => state.fetchFlags); + const fetchDashboard = useAdminStore((state) => state.fetchDashboard); const [showProjects, setShowProjects] = useState(true); const [loading, setLoading] = useState(true); const [lastUpdate, setLastUpdate] = useState(new Date()); useEffect(() => { - // Check if user logged in + // Check if user logged in, then load all dashboard data in one request. async function checkLoggedIn() { const loggedInRes = await postRequest('/admin/auth', 'admin', null); if (loggedInRes.status === 401) { @@ -41,6 +36,7 @@ const Admin = () => { } if (loggedInRes.status === 200) { setLoading(false); + fetchDashboard(); return; } @@ -48,20 +44,13 @@ const Admin = () => { } checkLoggedIn(); - fetchOptions(); - fetchFlags(); }, []); useEffect(() => { - // Set 15 seconds interval to refresh admin stats and clock + // Poll once every 15 seconds using a single /admin/dashboard request + // instead of the previous 6 separate API calls. const refresh = setInterval(async () => { - // Fetch stats and clock - fetchStats(); - fetchClock(); - fetchProjects(); - fetchJudges(); - fetchOptions(); - fetchFlags(); + fetchDashboard(); setLastUpdate(new Date()); }, 15000); diff --git a/client/src/store.tsx b/client/src/store.tsx index 6086cb2..5bee203 100644 --- a/client/src/store.tsx +++ b/client/src/store.tsx @@ -13,6 +13,7 @@ interface AdminStore { fetchJudgeStats: () => Promise; projectStats: ProjectStats; fetchProjectStats: () => Promise; + fetchDashboard: () => Promise; } const useAdminStore = create()((set) => ({ @@ -85,6 +86,25 @@ const useAdminStore = create()((set) => ({ } set({ projectStats: statsRes.data as ProjectStats }); }, + + // fetchDashboard replaces the 6 separate polling calls the admin page + // previously made every 15 seconds. One HTTP request, one DB round-trip + // set, all stores updated atomically. + fetchDashboard: async () => { + const selectedTrack = useOptionsStore.getState().selectedTrack; + const trackParam = selectedTrack !== '' ? `?track=${encodeURIComponent(selectedTrack)}` : ''; + const res = await getRequest(`/admin/dashboard${trackParam}`, 'admin'); + if (res.status !== 200) { + errorAlert(res); + return; + } + const data = res.data as DashboardResponse; + + set({ stats: data.stats, projects: data.projects, judges: data.judges }); + useClockStore.setState({ clock: data.clock }); + useOptionsStore.setState({ options: data.options }); + useFlagsStore.setState({ flags: data.flags }); + }, })); interface ClockStore { diff --git a/client/src/types.d.ts b/client/src/types.d.ts index 79d529e..da982b6 100644 --- a/client/src/types.d.ts +++ b/client/src/types.d.ts @@ -202,3 +202,12 @@ interface GroupInfo { names: string[]; enabled: boolean; } + +interface DashboardResponse { + stats: Stats; + clock: ClockState; + projects: Project[]; + judges: Judge[]; + options: Options; + flags: Flag[]; +} diff --git a/server/logging/logger.go b/server/logging/logger.go index ec07fcd..6109ed8 100644 --- a/server/logging/logger.go +++ b/server/logging/logger.go @@ -3,13 +3,11 @@ package logging import ( "context" "fmt" - "server/database" "server/models" "strings" "sync" "time" - "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" ) @@ -17,7 +15,6 @@ import ( type Logger struct { Mutex sync.Mutex Memory []string - DbRef *mongo.Database } type LogType int @@ -28,16 +25,14 @@ const ( Judge ) -const DbLogLimit = 10000 - func NewLogger(db *mongo.Database) (*Logger, error) { - // Get all logs from the db + // Load historical log entries from the database so the admin log + // view still shows entries from before the current server session. dbLogs, err := GetAllDbLogs(db) if err != nil { return nil, err } - // Create a new logger and add all logs from the database memory := []string{} for _, log := range dbLogs { memory = append(memory, log.Entries...) @@ -46,19 +41,16 @@ func NewLogger(db *mongo.Database) (*Logger, error) { return &Logger{ Mutex: sync.Mutex{}, Memory: memory, - DbRef: db, }, nil } // GetAllDbLogs retrieves all logs from the database. func GetAllDbLogs(db *mongo.Database) ([]*models.Log, error) { - // Get all logs from the database, sorted in ascending order of time - cursor, err := db.Collection("logs").Find(context.Background(), gin.H{}, options.Find().SetSort(gin.H{"time": 1})) + cursor, err := db.Collection("logs").Find(context.Background(), map[string]interface{}{}, options.Find().SetSort(map[string]interface{}{"time": 1})) if err != nil { return nil, err } - // Iterate through the cursor and decode each log logs := []*models.Log{} for cursor.Next(context.Background()) { var log models.Log @@ -66,71 +58,26 @@ func GetAllDbLogs(db *mongo.Database) ([]*models.Log, error) { if err != nil { return nil, err } - logs = append(logs, &log) } - // If no logs, create one - if len(logs) == 0 { - _, err = db.Collection("logs").InsertOne(context.Background(), models.NewLog()) - if err != nil { - return nil, err - } - } - return logs, nil } -func (l *Logger) writeToDb(item string) error { - return database.WithTransaction(l.DbRef, func(sc mongo.SessionContext) error { - // Insert the log into the database - res := l.DbRef.Collection("logs").FindOneAndUpdate( - sc, - gin.H{}, - gin.H{"$push": gin.H{"entries": item}, "$inc": gin.H{"count": 1}}, - options.FindOneAndUpdate().SetSort(gin.H{"time": -1}), - ) - - // Check for errors - if res.Err() != nil { - return res.Err() - } - - // Get the log from the result - var log models.Log - err := res.Decode(&log) - if err != nil { - return err - } - - // If the log is too long, create a new log in the db - if log.Count > DbLogLimit { - _, err = l.DbRef.Collection("logs").InsertOne(sc, models.NewLog()) - if err != nil { - return err - } - } - - return nil - }) -} - -// Logf logs a message to the log file. +// Logf logs a message to the in-memory log. // The time is prepended to the message and a newline is appended. +// Note: entries are no longer written to MongoDB on every call — doing so +// required a serialised majority-write transaction per API request and was +// the primary source of lock contention under concurrent judge load. func (l *Logger) Logf(t LogType, message string, args ...interface{}) error { l.Mutex.Lock() defer l.Mutex.Unlock() - // Form output string userInput := fmt.Sprintf(message, args...) output := fmt.Sprintf("[%s] %s | %s", time.Now().Format(time.RFC3339), typeToString(t), userInput) - // Write to memory l.Memory = append(l.Memory, output) - // Write to database - l.writeToDb(output) - return nil } @@ -140,7 +87,6 @@ func (l *Logger) SystemLogf(message string, args ...interface{}) error { } // JudgeLogf logs a message with the Judge LogType. -// This will include a judge and project (if defined) in the log message. func (l *Logger) JudgeLogf(judge *models.Judge, message string, args ...interface{}) error { judgeDisplay := "" if judge != nil { diff --git a/server/router/admin.go b/server/router/admin.go index ec539ad..c9e69e3 100644 --- a/server/router/admin.go +++ b/server/router/admin.go @@ -1,6 +1,7 @@ package router import ( + "context" "fmt" "net/http" "server/config" @@ -8,6 +9,7 @@ import ( "server/funcs" "server/models" "server/util" + "sync" "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/bson/primitive" @@ -55,36 +57,24 @@ func AdminAuthenticated(ctx *gin.Context) { // GET /admin/stats - GetAdminStats returns stats about the system func GetAdminStats(ctx *gin.Context) { - // Get the state from the context state := GetState(ctx) - - // Aggregate the stats - stats, err := database.AggregateStats(state.Db, "") + stats, err := state.GetCachedStats("") if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error aggregating stats: " + err.Error()}) return } - - // Send OK ctx.JSON(http.StatusOK, stats) } // GET /admin/stats/:track - GetAdminStats returns stats about the system func GetAdminTrackStats(ctx *gin.Context) { - // Get the state from the context state := GetState(ctx) - - // Get the track from the URL track := ctx.Param("track") - - // Aggregate the stats - stats, err := database.AggregateStats(state.Db, track) + stats, err := state.GetCachedStats(track) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error aggregating stats: " + err.Error()}) return } - - // Send OK ctx.JSON(http.StatusOK, stats) } @@ -227,6 +217,12 @@ func SetOptions(ctx *gin.Context) { return } + // Refresh the in-memory options cache so subsequent requests see the new values. + if err := state.ReloadOptions(ctx); err != nil { + // Non-fatal: log it but don't fail the request — DB write already succeeded. + state.Logger.AdminLogf("Warning: failed to refresh options cache: %s", err.Error()) + } + // Send OK state.Logger.AdminLogf("Updated options: %s", util.StructToStringWithoutNils(options)) ctx.JSON(http.StatusOK, gin.H{"ok": 1}) @@ -303,18 +299,8 @@ func GetFlags(ctx *gin.Context) { // GET /admin/options - returns all options func GetOptions(ctx *gin.Context) { - // Get the state from the context state := GetState(ctx) - - // Get the options - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return - } - - // Send OK - ctx.JSON(http.StatusOK, options) + ctx.JSON(http.StatusOK, state.GetCachedOptions()) } // POST /admin/export/judges - ExportJudges exports all judges to a CSV @@ -404,18 +390,8 @@ func ExportRankings(ctx *gin.Context) { // GET /admin/timer - GetJudgingTimer returns the judging timer func GetJudgingTimer(ctx *gin.Context) { - // Get the state from the context state := GetState(ctx) - - // Get the options - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return - } - - // Send OK - ctx.JSON(http.StatusOK, gin.H{"judging_timer": options.JudgingTimer}) + ctx.JSON(http.StatusOK, gin.H{"judging_timer": state.GetCachedOptions().JudgingTimer}) } // POST /admin/groups/num - SetNumGroups sets the number of groups @@ -444,6 +420,9 @@ func SetNumGroups(ctx *gin.Context) { return } + // Refresh options cache. + state.ReloadOptions(ctx) + // Send OK state.Logger.AdminLogf("Set num groups to %d", req.NumGroups) ctx.JSON(http.StatusOK, gin.H{"ok": 1}) @@ -475,6 +454,9 @@ func SetGroupSizes(ctx *gin.Context) { return } + // Refresh options cache. + state.ReloadOptions(ctx) + // Send OK state.Logger.AdminLogf("Set group sizes to %s", util.StructToStringWithoutNils(req)) ctx.JSON(http.StatusOK, gin.H{"ok": 1}) @@ -541,6 +523,9 @@ func GenerateQRCode(ctx *gin.Context) { return } + // Refresh options cache so CheckQRCode immediately sees the new code. + state.ReloadOptions(ctx) + // Send OK state.Logger.AdminLogf("Generated QR code") ctx.JSON(http.StatusOK, gin.H{"qr_code": token}) @@ -568,6 +553,9 @@ func GenerateTrackQRCode(ctx *gin.Context) { return } + // Refresh options cache so CheckTrackQRCode immediately sees the new code. + state.ReloadOptions(ctx) + // Send OK state.Logger.AdminLogf("Generated QR code for track %s", track) ctx.JSON(http.StatusOK, gin.H{"qr_code": token}) @@ -575,37 +563,15 @@ func GenerateTrackQRCode(ctx *gin.Context) { // GET /admin/qr - GetQRCode returns the QR code func GetQRCode(ctx *gin.Context) { - // Get the state from the context state := GetState(ctx) - - // Get the QR code - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return - } - - // Send OK - ctx.JSON(http.StatusOK, gin.H{"qr_code": options.QRCode}) + ctx.JSON(http.StatusOK, gin.H{"qr_code": state.GetCachedOptions().QRCode}) } // GET /admin/qr/:track - GetTrackQRCode returns the QR code for a track func GetTrackQRCode(ctx *gin.Context) { - // Get the state from the context state := GetState(ctx) - - // Get the track from the URL track := ctx.Param("track") - - // Get the QR code - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return - } - - // Send OK - ctx.JSON(http.StatusOK, gin.H{"qr_code": options.TrackQRCodes[track]}) + ctx.JSON(http.StatusOK, gin.H{"qr_code": state.GetCachedOptions().TrackQRCodes[track]}) } type CheckQRRequest struct { @@ -614,26 +580,15 @@ type CheckQRRequest struct { // POST /qr/check - CheckQRCode checks to see if the QR code is right func CheckQRCode(ctx *gin.Context) { - // Get the state from the context state := GetState(ctx) - // Get the request object var qrReq CheckQRRequest - err := ctx.BindJSON(&qrReq) - if err != nil { + if err := ctx.BindJSON(&qrReq); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "error reading request body: " + err.Error()}) return } - // Get the QR code - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return - } - - // Send OK if QR code is right - if options.QRCode == qrReq.Code { + if state.GetCachedOptions().QRCode == qrReq.Code { ctx.JSON(http.StatusOK, gin.H{"ok": 1}) } else { ctx.JSON(http.StatusOK, gin.H{"ok": 0}) @@ -642,29 +597,16 @@ func CheckQRCode(ctx *gin.Context) { // POST /admin/qr/:track - CheckTrackQRCode checks to see if the track QR code is right func CheckTrackQRCode(ctx *gin.Context) { - // Get the state from the context state := GetState(ctx) - // Get the request object var qrReq CheckQRRequest - err := ctx.BindJSON(&qrReq) - if err != nil { + if err := ctx.BindJSON(&qrReq); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "error reading request body: " + err.Error()}) return } - // Get the track from the URL track := ctx.Param("track") - - // Get the QR code - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return - } - - // Send OK if QR code is right - if options.TrackQRCodes[track] == qrReq.Code { + if state.GetCachedOptions().TrackQRCodes[track] == qrReq.Code { ctx.JSON(http.StatusOK, gin.H{"ok": 1}) } else { ctx.JSON(http.StatusOK, gin.H{"ok": 0}) @@ -702,6 +644,9 @@ func SetDeliberation(ctx *gin.Context) { state.Clock.Mutex.Unlock() } + // Refresh options cache so judges immediately see the deliberation state change. + state.ReloadOptions(ctx) + // Send OK hap := "Started" if !req.Start { @@ -713,20 +658,11 @@ func SetDeliberation(ctx *gin.Context) { // GET /group-info - GetGroupInfo returns the names of the groups and if groups are enabled func GetGroupInfo(ctx *gin.Context) { - // Get the state from the context state := GetState(ctx) - - // Get the options - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return - } - - // Send OK + opts := state.GetCachedOptions() ctx.JSON(http.StatusOK, gin.H{ - "names": options.GroupNames, - "enabled": options.MultiGroup, + "names": opts.GroupNames, + "enabled": opts.MultiGroup, }) } @@ -759,6 +695,9 @@ func SetBlockReqs(ctx *gin.Context) { // Update the limiter state.Limiter.Block = *req.BlockReqs + // Refresh options cache. + state.ReloadOptions(ctx) + // Send OK state.Logger.AdminLogf("Updated block requests to %t", *req.BlockReqs) ctx.JSON(http.StatusOK, gin.H{"ok": 1}) @@ -793,6 +732,9 @@ func SetMaxReqs(ctx *gin.Context) { // Update the limiter state.Limiter.MaxReqPerMin = int(*req.MaxReqPerMin) + // Refresh options cache. + state.ReloadOptions(ctx) + // Send OK state.Logger.AdminLogf("Updated max requests to %d", *req.MaxReqPerMin) ctx.JSON(http.StatusOK, gin.H{"ok": 1}) @@ -803,16 +745,12 @@ func SetTracks(ctx *gin.Context) { // Get the state from the context state := GetState(ctx) - // Get the options - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "could not get settings: " + err.Error()}) - return - } + // Use cached options (no DB read required). + options := state.GetCachedOptions() // Get the request var req models.OptionalOptions - err = ctx.BindJSON(&req) + err := ctx.BindJSON(&req) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "error parsing request: " + err.Error()}) return @@ -848,6 +786,9 @@ func SetTracks(ctx *gin.Context) { return } + // Refresh options cache. + state.ReloadOptions(ctx) + // Send OK state.Logger.AdminLogf("Updated tracks to %s", util.StructToStringWithoutNils(req)) ctx.JSON(http.StatusOK, gin.H{"ok": 1}) @@ -858,16 +799,12 @@ func SetTrackViews(ctx *gin.Context) { // Get the state from the context state := GetState(ctx) - // Get the options - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "could not get settings: " + err.Error()}) - return - } + // Use cached options (no DB read required). + options := state.GetCachedOptions() // Get the request var req models.OptionalOptions - err = ctx.BindJSON(&req) + err := ctx.BindJSON(&req) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "error parsing request: " + err.Error()}) return @@ -892,7 +829,138 @@ func SetTrackViews(ctx *gin.Context) { return } + // Refresh options cache. + state.ReloadOptions(ctx) + // Send OK state.Logger.AdminLogf("Updated track views to %s", util.StructToStringWithoutNils(req)) ctx.JSON(http.StatusOK, gin.H{"ok": 1}) } + +// DashboardResponse bundles all data needed by the admin panel into a single response. +type DashboardResponse struct { + Stats interface{} `json:"stats"` + Clock gin.H `json:"clock"` + Projects interface{} `json:"projects"` + Judges interface{} `json:"judges"` + Options interface{} `json:"options"` + Flags interface{} `json:"flags"` +} + +// GET /admin/dashboard - returns all admin panel data in one response. +// +// This replaces the six separate polling calls the admin frontend previously +// made every 15 seconds (stats, clock, projects, judges, options, flags). +// DB-bound queries run concurrently; clock and options are served from memory. +func GetDashboard(ctx *gin.Context) { + state := GetState(ctx) + + track := ctx.Query("track") + + // Clock and options come from in-process memory — zero DB cost. + state.Clock.Mutex.Lock() + clockData := gin.H{"running": state.Clock.State.Running, "time": state.Clock.State.GetDuration()} + state.Clock.Mutex.Unlock() + opts := state.GetCachedOptions() + + // Run the four DB-bound queries concurrently. + type result struct { + stats interface{} + projects interface{} + judges interface{} + flags interface{} + err error + } + ch := make(chan result, 1) + + go func() { + var r result + var wg sync.WaitGroup + var mu sync.Mutex + + wg.Add(4) + + go func() { + defer wg.Done() + stats, err := state.GetCachedStats(track) + mu.Lock() + if err != nil && r.err == nil { + r.err = err + } + r.stats = stats + mu.Unlock() + }() + + go func() { + defer wg.Done() + projects, err := database.FindAllProjects(state.Db, context.Background()) + if err != nil { + mu.Lock() + if r.err == nil { + r.err = err + } + mu.Unlock() + return + } + scores, err := state.GetCachedScores(context.Background()) + if err != nil { + mu.Lock() + if r.err == nil { + r.err = err + } + mu.Unlock() + return + } + for i, p := range projects { + if pScore, ok := scores[p.Id]; ok { + projects[i].Score = pScore.Score + projects[i].Stars = pScore.Stars + projects[i].TrackStars = pScore.TrackStars + } + } + mu.Lock() + r.projects = projects + mu.Unlock() + }() + + go func() { + defer wg.Done() + judges, err := database.FindAllJudges(state.Db, context.Background()) + mu.Lock() + if err != nil && r.err == nil { + r.err = err + } + r.judges = judges + mu.Unlock() + }() + + go func() { + defer wg.Done() + flags, err := database.FindAllFlags(state.Db) + mu.Lock() + if err != nil && r.err == nil { + r.err = err + } + r.flags = flags + mu.Unlock() + }() + + wg.Wait() + ch <- r + }() + + r := <-ch + if r.err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error fetching dashboard data: " + r.err.Error()}) + return + } + + ctx.JSON(http.StatusOK, DashboardResponse{ + Stats: r.stats, + Clock: clockData, + Projects: r.projects, + Judges: r.judges, + Options: opts, + Flags: r.flags, + }) +} diff --git a/server/router/init.go b/server/router/init.go index 281cb70..6fee88f 100644 --- a/server/router/init.go +++ b/server/router/init.go @@ -37,8 +37,16 @@ func NewRouter(db *mongo.Database, logger *logging.Logger) *gin.Engine { // Get the limiter from the database limiter := getLimiterFromDb(db) + // Load options once at startup into the in-memory cache so that all + // subsequent reads (especially inside judge transactions) are served + // from memory without hitting MongoDB. + initialOpts, err := database.GetOptions(db, context.Background()) + if err != nil { + log.Fatalf("error loading options into cache: %s\n", err.Error()) + } + // Add shared variables to router - state := NewState(db, clock, comps, logger, limiter) + state := NewState(db, clock, comps, logger, limiter, initialOpts) router.Use(useVar("state", state)) // CORS @@ -97,6 +105,9 @@ func NewRouter(db *mongo.Database, logger *logging.Logger) *gin.Engine { adminRouter.DELETE("/project/:id", DeleteProject) adminRouter.PUT("/project/:id", EditProject) + // Admin panel - single dashboard endpoint (replaces 6 separate polling calls) + adminRouter.GET("/admin/dashboard", GetDashboard) + // Admin panel - stats/data adminRouter.GET("/admin/stats", GetAdminStats) adminRouter.GET("/admin/stats/:track", GetAdminTrackStats) diff --git a/server/router/judge.go b/server/router/judge.go index cd7da51..d25c12b 100644 --- a/server/router/judge.go +++ b/server/router/judge.go @@ -398,22 +398,27 @@ func GetNextJudgeProject(ctx *gin.Context) { return } - // Otherwise, get the next project for the judge - err := database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { - // Get options - options, err := database.GetOptions(state.Db, sc) - if err != nil { - return errors.New("error getting options: " + err.Error()) - } - - // If the clock is paused, return an empty object - // This is to ensure that no projects are gotten if the clock is paused + // Read options from the in-memory cache — no DB round-trip required. + options := state.GetCachedOptions() + + // Check clock state under the mutex, but release it before entering the + // transaction to avoid holding two locks simultaneously. + // Previously this code had a deadlock: the mutex was acquired, then an + // early return was taken without releasing it, permanently blocking all + // subsequent clock operations. + clockRunning := func() bool { state.Clock.Mutex.Lock() - if !state.Clock.State.Running || options.Deliberation { - return nil - } - state.Clock.Mutex.Unlock() + defer state.Clock.Mutex.Unlock() + return state.Clock.State.Running + }() + + if !clockRunning || options.Deliberation { + ctx.JSON(http.StatusOK, gin.H{}) + return + } + // Otherwise, get the next project for the judge + err := database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { project, err := judging.PickNextProject(state.Db, sc, judge, state.Comps) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error picking next project: " + err.Error()}) @@ -527,18 +532,17 @@ func JudgeSkip(ctx *gin.Context) { // Skipped project ID id := judge.Current.Hex() - // Run remaining actions in a transaction - err = database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { - // Check if judging is paused - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return err - } + // Determine whether to assign the judge a new project after skipping. + // Read options from cache and clock state from in-memory state — no DB needed. + options := state.GetCachedOptions() + newProj := func() bool { state.Clock.Mutex.Lock() - newProj := state.Clock.State.Running && !options.Deliberation - state.Clock.Mutex.Unlock() + defer state.Clock.Mutex.Unlock() + return state.Clock.State.Running && !options.Deliberation + }() + // Run remaining actions in a transaction + err = database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { // Skip the project err = judging.SkipCurrentProjectWithTx(state.Db, sc, judge, state.Comps, skipReq.Reason, newProj) if err != nil { @@ -684,19 +688,16 @@ func JudgeFinish(ctx *gin.Context) { return } + // Read options from cache before the transaction — eliminates one DB + // round-trip per judge finish action. + options := state.GetCachedOptions() + if options.Deliberation { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot score due to deliberation mode being enabled"}) + return + } + // Run remaining actions in a transaction err = database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { - // Get the options and return error if deliberations - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return err - } - if options.Deliberation { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot score due to deliberation mode being enabled"}) - return err - } - // Get the project from the database project, err := database.FindProject(state.Db, sc, judge.Current) if err != nil { @@ -766,19 +767,14 @@ func JudgeRank(ctx *gin.Context) { return } + // Guard against deliberation mode before opening a transaction. + if state.GetCachedOptions().Deliberation { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot score due to deliberation mode being enabled"}) + return + } + // Wrap in transaction err = database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { - // Get the options and return error if deliberations - options, err := database.GetOptions(state.Db, sc) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return err - } - if options.Deliberation { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot score due to deliberation mode being enabled"}) - return err - } - // Calculate updated rankings judge.Rankings = rankReq.Ranking agg := judging.AggregateRanking(judge) @@ -830,26 +826,21 @@ func JudgeStar(ctx *gin.Context) { return } - // Wrap in transaction - err = database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { - // Get the options and return error if deliberations - options, err := database.GetOptions(state.Db, sc) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return err - } - if options.Deliberation { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot score due to deliberation mode being enabled"}) - return err - } + // Guard against deliberation mode before opening a transaction. + if state.GetCachedOptions().Deliberation { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot score due to deliberation mode being enabled"}) + return + } - // If the project isn't in the judge's seen projects, return an error - index := util.FindSeenProjectIndex(judge, projectId) - if index == -1 { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "judge hasn't seen project or project is invalid"}) - return err - } + // If the project isn't in the judge's seen projects, return an error + index := util.FindSeenProjectIndex(judge, projectId) + if index == -1 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "judge hasn't seen project or project is invalid"}) + return + } + // Wrap in transaction + err = database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { // Update the judge's object for the project err = database.UpdateJudgeStars(state.Db, sc, judge.Id, index, starReq.Starred) if err != nil { @@ -926,23 +917,16 @@ func ReassignJudgeGroups(ctx *gin.Context) { // Get the state from the context state := GetState(ctx) + // Check multi-group from cache before starting a transaction. + if !state.GetCachedOptions().MultiGroup { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "multi-group not enabled"}) + return + } + // Run the remaining actions in a transaction err := database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { - // Get options from database - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return err - } - - // Don't do if not enabled - if !options.MultiGroup { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "multi-group not enabled"}) - return errors.New("multi-group not enabled") - } - // Reassign judge groups - err = database.PutJudgesInGroups(state.Db) + err := database.PutJudgesInGroups(state.Db) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error reassigning judge groups: " + err.Error()}) return err @@ -1043,27 +1027,22 @@ func AddJudgeFromQR(ctx *gin.Context) { var judge *models.Judge - // Run the remaining actions in a transaction - err = database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { - // Get the options from the database - options, err := database.GetOptions(state.Db, sc) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return err + // Validate the QR code against the cached options before starting a transaction. + options := state.GetCachedOptions() + if qrReq.Track == "" { + if qrReq.Code != options.QRCode { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid QR code"}) + return } - - // Make sure the code is correct - if qrReq.Track == "" { - if qrReq.Code != options.QRCode { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid QR code"}) - return err - } - } else { - if qrReq.Code != options.TrackQRCodes[qrReq.Track] { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid QR code"}) - return err - } + } else { + if qrReq.Code != options.TrackQRCodes[qrReq.Track] { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid QR code"}) + return } + } + + // Run the remaining actions in a transaction + err = database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { // Check if the judge already exists judge, err = database.FindJudgeByCode(state.Db, sc, qrReq.Code) @@ -1128,17 +1107,8 @@ func AddJudgeFromQR(ctx *gin.Context) { // GET /judge/deliberation - Get the status of the deliberation func GetDeliberationStatus(ctx *gin.Context) { - // Get the state from the context state := GetState(ctx) - - // Get the options from the database - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return - } - - if options.Deliberation { + if state.GetCachedOptions().Deliberation { ctx.JSON(http.StatusOK, gin.H{"ok": 1}) } else { ctx.JSON(http.StatusOK, gin.H{"ok": 0}) diff --git a/server/router/project.go b/server/router/project.go index 2bcdacf..3a202c5 100644 --- a/server/router/project.go +++ b/server/router/project.go @@ -107,15 +107,11 @@ func AddProject(ctx *gin.Context) { var project *models.Project + // Read options from cache before the transaction. + options := state.GetCachedOptions() + // Run the remaining actions in a transaction err = database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { - // Get the options from the database - options, err := database.GetOptions(state.Db, sc) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options from database: " + err.Error()}) - return err - } - // If the challenge list contains the ignore track, skip the project ignore := false for _, ignoreTrack := range options.IgnoreTracks { @@ -176,8 +172,9 @@ func ListProjects(ctx *gin.Context) { return } - // Calculate the aggregated scores - scores, err := judging.AggregateScores(state.Db, ctx) + // Use the TTL-cached aggregation — avoids running the multi-stage + // judges→projects pipeline on every admin panel refresh. + scores, err := state.GetCachedScores(ctx) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error calculating scores: " + err.Error()}) return @@ -464,12 +461,7 @@ func GetProjectCount(ctx *gin.Context) { var count int64 - // Get the options from the database - options, err := database.GetOptions(state.Db, ctx) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options from database: " + err.Error()}) - return - } + options := state.GetCachedOptions() // If the tracks option is enabled, get the project count for the judge's track if options.JudgeTracks && judge.Track != "" { @@ -486,7 +478,7 @@ func GetProjectCount(ctx *gin.Context) { } // Get the project from the database - count, err = database.CountProjectDocuments(state.Db) + count, err := database.CountProjectDocuments(state.Db) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting project count from database: " + err.Error()}) return @@ -684,21 +676,15 @@ func MoveProjectGroup(ctx *gin.Context) { return } + // Validate group number from cache before starting a transaction. + cachedOpts := state.GetCachedOptions() + if moveReq.Group < 0 || moveReq.Group >= cachedOpts.NumGroups { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid group number"}) + return + } + // Run the remaining actions in a transaction err = database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { - // Get options from database - options, err := database.GetOptions(state.Db, sc) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return err - } - - // Make sure valid group - if moveReq.Group < 0 || moveReq.Group >= options.NumGroups { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid group number"}) - return errors.New("invalid group number") - } - // Get max number in group currMaxNum, err := database.GetGroupMaxNum(state.Db, sc, moveReq.Group) if err != nil { @@ -710,9 +696,9 @@ func MoveProjectGroup(ctx *gin.Context) { return errors.New("error getting group max number") } - // Get maximum number for the given group + // Get maximum number for the given group (use cached options closed over from above) maxGroupNum := int64(0) - for i, size := range options.GroupSizes { + for i, size := range cachedOpts.GroupSizes { maxGroupNum += size if int64(i) == moveReq.Group { break @@ -720,7 +706,7 @@ func MoveProjectGroup(ctx *gin.Context) { } // Make sure the group has space (check for max group number and make sure it doesn't exceed) - if moveReq.Group < options.NumGroups-1 && currMaxNum >= maxGroupNum { + if moveReq.Group < cachedOpts.NumGroups-1 && currMaxNum >= maxGroupNum { ctx.JSON(http.StatusBadRequest, gin.H{"error": "group is full, cannot move project to provided group"}) return err } @@ -818,21 +804,15 @@ func MoveSelectedProjectsGroup(ctx *gin.Context) { return } + // Validate group number from cache before starting a transaction. + cachedOpts2 := state.GetCachedOptions() + if moveReq.Group < 0 || moveReq.Group >= cachedOpts2.NumGroups { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid group number"}) + return + } + // Run the remaining actions in a transaction err = database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { - // Get options from database - options, err := database.GetOptions(state.Db, sc) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) - return err - } - - // Make sure valid group - if moveReq.Group < 0 || moveReq.Group >= options.NumGroups { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid group number"}) - return errors.New("invalid group number") - } - // Get max number in group currMaxNum, err := database.GetGroupMaxNum(state.Db, ctx, moveReq.Group) if err != nil { @@ -844,9 +824,9 @@ func MoveSelectedProjectsGroup(ctx *gin.Context) { return errors.New("error getting group max number") } - // Get maximum number for the given group + // Get maximum number for the given group (use cached options closed over from above) maxGroupNum := int64(0) - for i, size := range options.GroupSizes { + for i, size := range cachedOpts2.GroupSizes { maxGroupNum += size if int64(i) == moveReq.Group { break @@ -854,7 +834,7 @@ func MoveSelectedProjectsGroup(ctx *gin.Context) { } // Check if the group has space (check for max group number and make sure it doesn't exceed) - if moveReq.Group < options.NumGroups-1 && currMaxNum+int64(len(moveReq.Items)) > maxGroupNum { + if moveReq.Group < cachedOpts2.NumGroups-1 && currMaxNum+int64(len(moveReq.Items)) > maxGroupNum { ctx.JSON(http.StatusBadRequest, gin.H{"error": "group is full, cannot move projects to provided group"}) return errors.New("group is full, cannot move projects to provided group") } diff --git a/server/router/state.go b/server/router/state.go index 6eea2ad..67c77f3 100644 --- a/server/router/state.go +++ b/server/router/state.go @@ -1,32 +1,148 @@ package router import ( + "context" + "server/database" "server/judging" "server/logging" "server/models" + "sync" + "time" "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" ) +// cachedStats holds a time-limited copy of AggregateStats results. +type cachedStats struct { + value *models.Stats + expiresAt time.Time +} + +// cachedScores holds a time-limited copy of AggregateScores results. +type cachedScores struct { + value map[primitive.ObjectID]judging.ProjectScores + expiresAt time.Time +} + +const aggCacheTTL = 10 * time.Second + +// State is the shared application state attached to every request via middleware. type State struct { Db *mongo.Database Clock *models.SafeClock Comps *judging.Comparisons Logger *logging.Logger Limiter *Limiter + + // In-memory options cache — avoids a DB round-trip for every judge action. + optsMu sync.RWMutex + opts *models.Options + + // Short-lived caches for the expensive aggregation pipelines that power + // the admin dashboard. A 10-second TTL means concurrent admin sessions + // share a single aggregation result instead of each triggering their own. + statsCacheMu sync.RWMutex + statsCache map[string]*cachedStats // keyed by track ("" = general) + + scoresCacheMu sync.RWMutex + scoresCache *cachedScores } -func NewState(db *mongo.Database, clock *models.SafeClock, comps *judging.Comparisons, logger *logging.Logger, limiter *Limiter) *State { +func NewState(db *mongo.Database, clock *models.SafeClock, comps *judging.Comparisons, logger *logging.Logger, limiter *Limiter, opts *models.Options) *State { return &State{ - Db: db, - Clock: clock, - Comps: comps, - Logger: logger, - Limiter: limiter, + Db: db, + Clock: clock, + Comps: comps, + Logger: logger, + Limiter: limiter, + opts: opts, + statsCache: make(map[string]*cachedStats), } } +// GetCachedOptions returns the in-memory options without hitting the database. +func (s *State) GetCachedOptions() *models.Options { + s.optsMu.RLock() + defer s.optsMu.RUnlock() + return s.opts +} + +// SetCachedOptions replaces the in-memory options cache. +func (s *State) SetCachedOptions(o *models.Options) { + s.optsMu.Lock() + defer s.optsMu.Unlock() + s.opts = o +} + +// ReloadOptions re-reads options from the database and refreshes the cache. +// Call this after any operation that modifies the options document. +func (s *State) ReloadOptions(ctx context.Context) error { + opts, err := database.GetOptions(s.Db, ctx) + if err != nil { + return err + } + s.SetCachedOptions(opts) + return nil +} + +// GetCachedStats returns AggregateStats for the given track, using a 10-second +// TTL cache to prevent redundant aggregation pipelines when multiple admins poll +// simultaneously. +func (s *State) GetCachedStats(track string) (*models.Stats, error) { + s.statsCacheMu.RLock() + entry, ok := s.statsCache[track] + s.statsCacheMu.RUnlock() + + if ok && time.Now().Before(entry.expiresAt) { + return entry.value, nil + } + + stats, err := database.AggregateStats(s.Db, track) + if err != nil { + return nil, err + } + + s.statsCacheMu.Lock() + s.statsCache[track] = &cachedStats{value: stats, expiresAt: time.Now().Add(aggCacheTTL)} + s.statsCacheMu.Unlock() + + return stats, nil +} + +// GetCachedScores returns AggregateScores, using a 10-second TTL cache to +// avoid running the multi-stage judges→projects aggregation pipeline for +// every admin refresh cycle. +func (s *State) GetCachedScores(ctx context.Context) (map[primitive.ObjectID]judging.ProjectScores, error) { + s.scoresCacheMu.RLock() + entry := s.scoresCache + s.scoresCacheMu.RUnlock() + + if entry != nil && time.Now().Before(entry.expiresAt) { + return entry.value, nil + } + + scores, err := judging.AggregateScores(s.Db, ctx) + if err != nil { + return nil, err + } + + s.scoresCacheMu.Lock() + s.scoresCache = &cachedScores{value: scores, expiresAt: time.Now().Add(aggCacheTTL)} + s.scoresCacheMu.Unlock() + + return scores, nil +} + +// InvalidateScoresCache clears the scores cache, forcing the next request to +// recompute. Call this after judging actions that change scores. +func (s *State) InvalidateScoresCache() { + s.scoresCacheMu.Lock() + s.scoresCache = nil + s.scoresCacheMu.Unlock() +} + func GetState(ctx *gin.Context) *State { state := ctx.MustGet("state").(*State) return state