aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-15 11:32:38 +0900
committernsfisis <nsfisis@gmail.com>2026-02-15 11:32:38 +0900
commit557b238e88189e1314c39af82d77c94ba9dbd19e (patch)
tree2014b2988fdc7ae90cce30159402ff3ac215725f
parenta6b88139afc7c994ddb604757304d44214c00a90 (diff)
downloadphperkaigi-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>
-rw-r--r--backend/admin/handler.go53
-rw-r--r--backend/api/handler.go90
-rw-r--r--backend/api/handler_wrapper.go5
-rw-r--r--backend/auth/auth.go28
-rw-r--r--backend/game/hub.go74
-rw-r--r--backend/gen/api/handler_wrapper_gen.go5
-rw-r--r--backend/main.go6
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)