diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-15 11:32:38 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-15 11:32:38 +0900 |
| commit | 557b238e88189e1314c39af82d77c94ba9dbd19e (patch) | |
| tree | 2014b2988fdc7ae90cce30159402ff3ac215725f /backend | |
| parent | a6b88139afc7c994ddb604757304d44214c00a90 (diff) | |
| download | phperkaigi-2026-albatross-557b238e88189e1314c39af82d77c94ba9dbd19e.tar.gz phperkaigi-2026-albatross-557b238e88189e1314c39af82d77c94ba9dbd19e.tar.zst phperkaigi-2026-albatross-557b238e88189e1314c39af82d77c94ba9dbd19e.zip | |
fix(backend): resolve TODO items for transactions, validation, and error handling
- Wrap multi-step DB operations in transactions (signup, submit, game
edit, task result processing)
- Add game running checks to PostGamePlayCode and PostGamePlaySubmit
- Hide ranking code when game is not yet finished
- Replace silenced errors in processTaskResults with slog.Error logging
- Add pgxpool.Pool to Handler/Hub structs for transaction support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/admin/handler.go | 53 | ||||
| -rw-r--r-- | backend/api/handler.go | 90 | ||||
| -rw-r--r-- | backend/api/handler_wrapper.go | 5 | ||||
| -rw-r--r-- | backend/auth/auth.go | 28 | ||||
| -rw-r--r-- | backend/game/hub.go | 74 | ||||
| -rw-r--r-- | backend/gen/api/handler_wrapper_gen.go | 5 | ||||
| -rw-r--r-- | backend/main.go | 6 |
7 files changed, 204 insertions, 57 deletions
diff --git a/backend/admin/handler.go b/backend/admin/handler.go index ef19f21..e8a7921 100644 --- a/backend/admin/handler.go +++ b/backend/admin/handler.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v4" "albatross-2026-backend/account" @@ -22,11 +23,12 @@ var jst = time.FixedZone("Asia/Tokyo", 9*60*60) type Handler struct { q *db.Queries + pool *pgxpool.Pool conf *config.Config } -func NewHandler(q *db.Queries, conf *config.Config) *Handler { - return &Handler{q: q, conf: conf} +func NewHandler(q *db.Queries, pool *pgxpool.Pool, conf *config.Config) *Handler { + return &Handler{q: q, pool: pool, conf: conf} } func (h *Handler) newAdminMiddleware() echo.MiddlewareFunc { @@ -485,20 +487,6 @@ func (h *Handler) postGameEdit(c echo.Context) error { } } - // TODO: transaction - err = h.q.UpdateGame(c.Request().Context(), db.UpdateGameParams{ - GameID: int32(gameID), - GameType: gameType, - IsPublic: isPublic, - DisplayName: displayName, - DurationSeconds: int32(durationSeconds), - StartedAt: changedStartedAt, - ProblemID: int32(problemID), - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - mainPlayers := []int{} mainPlayer1Raw := c.FormValue("main_player_1") if mainPlayer1Raw != "" && mainPlayer1Raw != "0" { @@ -517,12 +505,37 @@ func (h *Handler) postGameEdit(c echo.Context) error { mainPlayers = append(mainPlayers, mainPlayer2) } - err = h.q.RemoveAllMainPlayers(c.Request().Context(), int32(gameID)) + ctx := c.Request().Context() + tx, err := h.pool.Begin(ctx) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + defer func() { + if err := tx.Rollback(ctx); err != nil && err != pgx.ErrTxClosed { + slog.Error("failed to rollback transaction", "error", err) + } + }() + + qtx := h.q.WithTx(tx) + err = qtx.UpdateGame(ctx, db.UpdateGameParams{ + GameID: int32(gameID), + GameType: gameType, + IsPublic: isPublic, + DisplayName: displayName, + DurationSeconds: int32(durationSeconds), + StartedAt: changedStartedAt, + ProblemID: int32(problemID), + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + err = qtx.RemoveAllMainPlayers(ctx, int32(gameID)) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } for _, userID := range mainPlayers { - err = h.q.AddMainPlayer(c.Request().Context(), db.AddMainPlayerParams{ + err = qtx.AddMainPlayer(ctx, db.AddMainPlayerParams{ GameID: int32(gameID), UserID: int32(userID), }) @@ -531,6 +544,10 @@ func (h *Handler) postGameEdit(c echo.Context) error { } } + if err := tx.Commit(ctx); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/games") } diff --git a/backend/api/handler.go b/backend/api/handler.go index 05a185a..3fe7e3c 100644 --- a/backend/api/handler.go +++ b/backend/api/handler.go @@ -11,6 +11,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v4" "github.com/oapi-codegen/nullable" @@ -21,6 +22,7 @@ import ( type Handler struct { q *db.Queries + pool *pgxpool.Pool hub GameHubInterface conf *config.Config } @@ -45,7 +47,7 @@ func (r postLoginCookieResponse) VisitPostLoginResponse(w http.ResponseWriter) e func (h *Handler) PostLogin(ctx context.Context, request PostLoginRequestObject) (PostLoginResponseObject, error) { username := request.Body.Username password := request.Body.Password - userID, err := auth.Login(ctx, h.q, username, password) + userID, err := auth.Login(ctx, h.q, h.pool, username, password) if err != nil { slog.Error("login failed", "error", err) var msg string @@ -321,6 +323,18 @@ func (h *Handler) GetGameWatchLatestStates(ctx context.Context, request GetGameW func (h *Handler) GetGameWatchRanking(ctx context.Context, request GetGameWatchRankingRequestObject, _ *db.User) (GetGameWatchRankingResponseObject, error) { gameID := request.GameID + + gameRow, err := h.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return GetGameWatchRanking404JSONResponse{ + Message: "Game not found", + }, nil + } + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + gameFinished := isGameFinished(gameRow) + rows, err := h.q.GetRanking(ctx, int32(gameID)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -329,8 +343,12 @@ func (h *Handler) GetGameWatchRanking(ctx context.Context, request GetGameWatchR } ranking := make([]RankingEntry, len(rows)) for i, row := range rows { - // TODO: check if game is finished. - code := &row.Submission.Code + var code nullable.Nullable[string] + if gameFinished { + code = nullable.NewNullableWithValue(row.Submission.Code) + } else { + code = nullable.NewNullNullable[string]() + } ranking[i] = RankingEntry{ Player: User{ UserID: int(row.User.UserID), @@ -342,7 +360,7 @@ func (h *Handler) GetGameWatchRanking(ctx context.Context, request GetGameWatchR }, Score: int(row.Submission.CodeSize), SubmittedAt: row.Submission.CreatedAt.Time.Unix(), - Code: toNullable(code), + Code: code, } } return GetGameWatchRanking200JSONResponse{ @@ -352,8 +370,23 @@ func (h *Handler) GetGameWatchRanking(ctx context.Context, request GetGameWatchR func (h *Handler) PostGamePlayCode(ctx context.Context, request PostGamePlayCodeRequestObject, user *db.User) (PostGamePlayCodeResponseObject, error) { gameID := request.GameID - // TODO: check if the game is running - err := h.q.UpdateCode(ctx, db.UpdateCodeParams{ + + gameRow, err := h.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return PostGamePlayCode404JSONResponse{ + Message: "Game not found", + }, nil + } + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + if !isGameRunning(gameRow) { + return PostGamePlayCode403JSONResponse{ + Message: "Game is not running", + }, nil + } + + err = h.q.UpdateCode(ctx, db.UpdateCodeParams{ GameID: int32(gameID), UserID: user.UserID, Code: request.Body.Code, @@ -379,9 +412,25 @@ func (h *Handler) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySu language := gameRow.Language codeSize := h.hub.CalcCodeSize(code, language) - // TODO: check if the game is running - // TODO: transaction - err = h.q.UpdateCodeAndStatus(ctx, db.UpdateCodeAndStatusParams{ + + if !isGameRunning(gameRow) { + return PostGamePlaySubmit403JSONResponse{ + Message: "Game is not running", + }, nil + } + + tx, err := h.pool.Begin(ctx) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + defer func() { + if err := tx.Rollback(ctx); err != nil && err != pgx.ErrTxClosed { + slog.Error("failed to rollback transaction", "error", err) + } + }() + + qtx := h.q.WithTx(tx) + err = qtx.UpdateCodeAndStatus(ctx, db.UpdateCodeAndStatusParams{ GameID: int32(gameID), UserID: user.UserID, Code: code, @@ -390,7 +439,7 @@ func (h *Handler) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySu if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - submissionID, err := h.q.CreateSubmission(ctx, db.CreateSubmissionParams{ + submissionID, err := qtx.CreateSubmission(ctx, db.CreateSubmissionParams{ GameID: int32(gameID), UserID: user.UserID, Code: code, @@ -399,6 +448,11 @@ func (h *Handler) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySu if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } + + if err := tx.Commit(ctx); err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + err = h.hub.EnqueueTestTasks(ctx, int(submissionID), gameID, int(user.UserID), language, code) if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) @@ -500,6 +554,22 @@ func (h *Handler) GetTournament(ctx context.Context, request GetTournamentReques }, nil } +func isGameRunning(game db.GetGameByIDRow) bool { + if !game.StartedAt.Valid { + return false + } + endTime := game.StartedAt.Time.Add(time.Duration(game.DurationSeconds) * time.Second) + return time.Now().Before(endTime) +} + +func isGameFinished(game db.GetGameByIDRow) bool { + if !game.StartedAt.Valid { + return false + } + endTime := game.StartedAt.Time.Add(time.Duration(game.DurationSeconds) * time.Second) + return !time.Now().Before(endTime) +} + func toNullable[T any](p *T) nullable.Nullable[T] { if p == nil { return nullable.NewNullNullable[T]() diff --git a/backend/api/handler_wrapper.go b/backend/api/handler_wrapper.go index 8e3e8cd..7448d13 100644 --- a/backend/api/handler_wrapper.go +++ b/backend/api/handler_wrapper.go @@ -5,6 +5,8 @@ package api import ( "context" + "github.com/jackc/pgx/v5/pgxpool" + "albatross-2026-backend/config" "albatross-2026-backend/db" ) @@ -15,10 +17,11 @@ type HandlerWrapper struct { impl Handler } -func NewHandler(queries *db.Queries, hub GameHubInterface, conf *config.Config) *HandlerWrapper { +func NewHandler(queries *db.Queries, pool *pgxpool.Pool, hub GameHubInterface, conf *config.Config) *HandlerWrapper { return &HandlerWrapper{ impl: Handler{ q: queries, + pool: pool, hub: hub, conf: conf, }, diff --git a/backend/auth/auth.go b/backend/auth/auth.go index babbe68..b79161f 100644 --- a/backend/auth/auth.go +++ b/backend/auth/auth.go @@ -7,6 +7,7 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "albatross-2026-backend/account" @@ -25,6 +26,7 @@ const ( func Login( ctx context.Context, queries *db.Queries, + pool *pgxpool.Pool, username string, password string, ) (int, error) { @@ -47,12 +49,13 @@ func Login( } // Authenticate with fortee. - return verifyForteeAccountOrSignup(ctx, queries, username, password) + return verifyForteeAccountOrSignup(ctx, queries, pool, username, password) } func verifyForteeAccountOrSignup( ctx context.Context, queries *db.Queries, + pool *pgxpool.Pool, username string, password string, ) (int, error) { @@ -66,6 +69,7 @@ func verifyForteeAccountOrSignup( return signup( ctx, queries, + pool, canonicalizedUsername, ) } @@ -77,19 +81,35 @@ func verifyForteeAccountOrSignup( func signup( ctx context.Context, queries *db.Queries, + pool *pgxpool.Pool, username string, ) (int, error) { - // TODO: transaction - userID, err := queries.CreateUser(ctx, username) + tx, err := pool.Begin(ctx) if err != nil { return 0, err } - if err := queries.CreateUserAuth(ctx, db.CreateUserAuthParams{ + defer func() { + if err := tx.Rollback(ctx); err != nil && err != pgx.ErrTxClosed { + slog.Error("failed to rollback transaction", "error", err) + } + }() + + qtx := queries.WithTx(tx) + userID, err := qtx.CreateUser(ctx, username) + if err != nil { + return 0, err + } + if err := qtx.CreateUserAuth(ctx, db.CreateUserAuthParams{ UserID: userID, AuthType: "fortee", }); err != nil { return 0, err } + + if err := tx.Commit(ctx); err != nil { + return 0, err + } + go func() { err := account.FetchIcon(context.Background(), queries, int(userID)) if err != nil { diff --git a/backend/game/hub.go b/backend/game/hub.go index 9ae725a..c9e9680 100644 --- a/backend/game/hub.go +++ b/backend/game/hub.go @@ -7,20 +7,25 @@ import ( "regexp" "strings" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "albatross-2026-backend/db" "albatross-2026-backend/taskqueue" ) type Hub struct { q *db.Queries + pool *pgxpool.Pool ctx context.Context taskQueue *taskqueue.Queue taskWorker *taskqueue.WorkerServer } -func NewGameHub(q *db.Queries, taskQueue *taskqueue.Queue, taskWorker *taskqueue.WorkerServer) *Hub { +func NewGameHub(q *db.Queries, pool *pgxpool.Pool, taskQueue *taskqueue.Queue, taskWorker *taskqueue.WorkerServer) *Hub { return &Hub{ q: q, + pool: pool, ctx: context.Background(), taskQueue: taskQueue, taskWorker: taskWorker, @@ -74,37 +79,66 @@ func (hub *Hub) processTaskResults() { for taskResult := range hub.taskWorker.Results() { switch taskResult := taskResult.(type) { case *taskqueue.TaskResultRunTestcase: - // TODO: error handling - _ = hub.processTaskResultRunTestcase(taskResult) - aggregatedStatus, _ := hub.q.AggregateTestcaseResults(hub.ctx, int32(taskResult.TaskPayload.SubmissionID)) + if err := hub.processTaskResultRunTestcase(taskResult); err != nil { + slog.Error("failed to process testcase result", "error", err, "submissionID", taskResult.TaskPayload.SubmissionID) + continue + } + aggregatedStatus, err := hub.q.AggregateTestcaseResults(hub.ctx, int32(taskResult.TaskPayload.SubmissionID)) + if err != nil { + slog.Error("failed to aggregate testcase results", "error", err, "submissionID", taskResult.TaskPayload.SubmissionID) + continue + } if aggregatedStatus == "running" { continue } - // TODO: error handling - // TODO: transaction - _ = hub.q.UpdateSubmissionStatus(hub.ctx, db.UpdateSubmissionStatusParams{ - SubmissionID: int32(taskResult.TaskPayload.SubmissionID), - Status: aggregatedStatus, - }) - _ = hub.q.UpdateGameStateStatus(hub.ctx, db.UpdateGameStateStatusParams{ - GameID: int32(taskResult.TaskPayload.GameID), - UserID: int32(taskResult.TaskPayload.UserID), - Status: aggregatedStatus, - }) - if aggregatedStatus != "success" { + if err := hub.updateSubmissionAndGameState(taskResult, aggregatedStatus); err != nil { + slog.Error("failed to update submission and game state", "error", err, "submissionID", taskResult.TaskPayload.SubmissionID) continue } - _ = hub.q.SyncGameStateBestScoreSubmission(hub.ctx, db.SyncGameStateBestScoreSubmissionParams{ - GameID: int32(taskResult.TaskPayload.GameID), - UserID: int32(taskResult.TaskPayload.UserID), - }) default: panic("unexpected task result type") } } } +func (hub *Hub) updateSubmissionAndGameState(taskResult *taskqueue.TaskResultRunTestcase, aggregatedStatus string) error { + tx, err := hub.pool.Begin(hub.ctx) + if err != nil { + return err + } + defer func() { + if err := tx.Rollback(hub.ctx); err != nil && err != pgx.ErrTxClosed { + slog.Error("failed to rollback transaction", "error", err) + } + }() + + qtx := hub.q.WithTx(tx) + if err := qtx.UpdateSubmissionStatus(hub.ctx, db.UpdateSubmissionStatusParams{ + SubmissionID: int32(taskResult.TaskPayload.SubmissionID), + Status: aggregatedStatus, + }); err != nil { + return err + } + if err := qtx.UpdateGameStateStatus(hub.ctx, db.UpdateGameStateStatusParams{ + GameID: int32(taskResult.TaskPayload.GameID), + UserID: int32(taskResult.TaskPayload.UserID), + Status: aggregatedStatus, + }); err != nil { + return err + } + if aggregatedStatus == "success" { + if err := qtx.SyncGameStateBestScoreSubmission(hub.ctx, db.SyncGameStateBestScoreSubmissionParams{ + GameID: int32(taskResult.TaskPayload.GameID), + UserID: int32(taskResult.TaskPayload.UserID), + }); err != nil { + return err + } + } + + return tx.Commit(hub.ctx) +} + func (hub *Hub) processTaskResultRunTestcase( taskResult *taskqueue.TaskResultRunTestcase, ) error { diff --git a/backend/gen/api/handler_wrapper_gen.go b/backend/gen/api/handler_wrapper_gen.go index 3a9d31f..1a948a7 100644 --- a/backend/gen/api/handler_wrapper_gen.go +++ b/backend/gen/api/handler_wrapper_gen.go @@ -105,6 +105,8 @@ package api import ( "context" + "github.com/jackc/pgx/v5/pgxpool" + "albatross-2026-backend/config" "albatross-2026-backend/db" ) @@ -115,10 +117,11 @@ type HandlerWrapper struct { impl Handler } -func NewHandler(queries *db.Queries, hub GameHubInterface, conf *config.Config) *HandlerWrapper { +func NewHandler(queries *db.Queries, pool *pgxpool.Pool, hub GameHubInterface, conf *config.Config) *HandlerWrapper { return &HandlerWrapper{ impl: Handler{ q: queries, + pool: pool, hub: hub, conf: conf, }, diff --git a/backend/main.go b/backend/main.go index ac996f0..01ed784 100644 --- a/backend/main.go +++ b/backend/main.go @@ -91,7 +91,7 @@ func main() { taskQueue := taskqueue.NewQueue("task-db:6379") workerServer := taskqueue.NewWorkerServer("task-db:6379") - gameHub := game.NewGameHub(queries, taskQueue, workerServer) + gameHub := game.NewGameHub(queries, connPool, taskQueue, workerServer) loginRL := ratelimit.NewIPRateLimiter(rate.Every(time.Minute/5), 5) @@ -99,10 +99,10 @@ func main() { apiGroup.Use(ratelimit.LoginRateLimitMiddleware(loginRL)) apiGroup.Use(api.SessionCookieMiddleware(queries)) apiGroup.Use(oapimiddleware.OapiRequestValidator(openAPISpec)) - apiHandler := api.NewHandler(queries, gameHub, conf) + apiHandler := api.NewHandler(queries, connPool, gameHub, conf) api.RegisterHandlers(apiGroup, api.NewStrictHandler(apiHandler, nil)) - adminHandler := admin.NewHandler(queries, conf) + adminHandler := admin.NewHandler(queries, connPool, conf) adminGroup := e.Group(conf.BasePath + "admin") adminGroup.Use(api.SessionCookieMiddleware(queries)) adminHandler.RegisterHandlers(adminGroup) |
