diff --git a/api/.env.example b/api/.env.example index 939dc268..0bbe193c 100644 --- a/api/.env.example +++ b/api/.env.example @@ -36,6 +36,11 @@ DEFAULT_QUESTION_POINTS=1 # MAXIMUM TIME TO SHOW SCOREBOARD AFTER QUESTION COMPLETE SCOREBOARD_MAX_DURATION=10 +# Auto-terminate active quiz sessions running longer than this (hours, from start). Default 24. +ACTIVE_QUIZ_TTL_HOURS=24 +# How often the background sweeper checks for expired sessions (minutes). Default 60. +ACTIVE_QUIZ_SWEEP_MINUTES=60 + MIGRATION_DIR=database/migrations # SQLITE_FILEPATH=database/jovvix.db @@ -72,4 +77,4 @@ MAX_QUIZ_FILE_SIZE=3145728 BODY_LIMIT_MB=15 #MB # Comma-separated emails allowed to create public quizzes (visible on the homepage). -PUBLIC_QUIZ_ADMIN_EMAILS= \ No newline at end of file +PUBLIC_QUIZ_ADMIN_EMAILS= diff --git a/api/cli/api.go b/api/cli/api.go index 00a38014..274527bf 100644 --- a/api/cli/api.go +++ b/api/cli/api.go @@ -5,11 +5,13 @@ import ( "os" "os/signal" "syscall" + "time" "go.uber.org/zap" "github.com/Improwised/jovvix/api/config" "github.com/Improwised/jovvix/api/database" + "github.com/Improwised/jovvix/api/models" pMetrics "github.com/Improwised/jovvix/api/pkg/prometheus" "github.com/Improwised/jovvix/api/routes" fiber "github.com/gofiber/fiber/v2" @@ -51,6 +53,28 @@ func GetAPICommandDef(cfg config.AppConfig, logger *zap.Logger) cobra.Command { return err } + // Background sweeper: every ACTIVE_QUIZ_SWEEP_MINUTES, auto-terminate any quiz + activeQuizModel := models.InitActiveQuizModel(db, logger) + + ttl := time.Duration(cfg.Quiz.ActiveQuizTTLHours) * time.Hour // + if ttl <= 0 { + ttl = 24 * time.Hour + } + sweepInterval := time.Duration(cfg.Quiz.ActiveQuizSweepMinutes) * time.Minute + if sweepInterval <= 0 { + sweepInterval = 60 * time.Minute + } + go func() { + for { + if count, err := activeQuizModel.DeactivateExpired(ttl); err != nil { + logger.Error("active quiz sweeper failed", zap.Error(err)) + } else if count > 0 { + logger.Info("active quiz sweeper: deactivated expired sessions", zap.Int64("count", count)) + } + time.Sleep(sweepInterval) + } + }() + interrupt := make(chan os.Signal, 1) signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/api/config/quiz.go b/api/config/quiz.go index a9fb3ce9..20acf24f 100644 --- a/api/config/quiz.go +++ b/api/config/quiz.go @@ -3,11 +3,13 @@ package config import "strings" type QuizConfig struct { - QuestionTimeLimit string `envconfig:"QUESTION_TIME_LIMIT"` - DefaultQuestionPoints int16 `envconfig:"DEFAULT_QUESTION_POINTS"` - ScoreboardMaxDuration string `envconfig:"SCOREBOARD_MAX_DURATION"` - FileSize int64 `envconfig:"MAX_QUIZ_FILE_SIZE"` - PublicQuizAdminEmails []string `envconfig:"PUBLIC_QUIZ_ADMIN_EMAILS"` + QuestionTimeLimit string `envconfig:"QUESTION_TIME_LIMIT"` + DefaultQuestionPoints int16 `envconfig:"DEFAULT_QUESTION_POINTS"` + ScoreboardMaxDuration string `envconfig:"SCOREBOARD_MAX_DURATION"` + FileSize int64 `envconfig:"MAX_QUIZ_FILE_SIZE"` + PublicQuizAdminEmails []string `envconfig:"PUBLIC_QUIZ_ADMIN_EMAILS"` + ActiveQuizTTLHours int `envconfig:"ACTIVE_QUIZ_TTL_HOURS"` + ActiveQuizSweepMinutes int `envconfig:"ACTIVE_QUIZ_SWEEP_MINUTES"` } // IsPublicQuizAdmin reports whether the given email is allowed to publish public quizzes. diff --git a/api/models/active_quizzes.go b/api/models/active_quizzes.go index 6dc64ffb..a8d3d100 100644 --- a/api/models/active_quizzes.go +++ b/api/models/active_quizzes.go @@ -365,6 +365,28 @@ func (model *ActiveQuizModel) Deactivate(id uuid.UUID) error { } +func (model *ActiveQuizModel) DeactivateExpired(ttl time.Duration) (int64, error) { + ttlSeconds := int64(ttl.Seconds()) + + result, err := model.db.Update(ActiveQuizzesTable).Set(goqu.Record{ + "invitation_code": nil, + "is_active": false, + "activated_to": goqu.L("now()"), + "current_question": nil, + "is_question_active": nil, + "updated_at": goqu.L("now()"), + }).Where( + goqu.I("is_active").Eq(true), + goqu.L("activated_from < now() - ? * interval '1 second'", ttlSeconds), + ).Executor().Exec() + + if err != nil { + return 0, err + } + + return result.RowsAffected() +} + func (model *ActiveQuizModel) GetCurrentActiveQuestion(id uuid.UUID) (uuid.UUID, error) { var currentQuestion uuid.UUID found, err := model.db.Select("current_question").From(ActiveQuizzesTable).Where(goqu.I("id").Eq(id), goqu.I("is_question_active").Eq(true)).ScanVal(¤tQuestion)