From 6c3e32b2cf4624c7dcc85e5be02d10f94fa371a4 Mon Sep 17 00:00:00 2001 From: Eraxyso <130852025+Eraxyso@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:30:21 +0000 Subject: [PATCH 1/4] fix: prevent race on duplicate respondent insert --- model/respondents_impl.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/model/respondents_impl.go b/model/respondents_impl.go index 86be705b..442d89ea 100755 --- a/model/respondents_impl.go +++ b/model/respondents_impl.go @@ -12,6 +12,7 @@ import ( "gopkg.in/guregu/null.v4" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // Respondent RespondentRepositoryの実装 @@ -99,21 +100,21 @@ func (*Respondent) InsertRespondent(ctx context.Context, userID string, question } if !questionnaire.IsDuplicateAnswerAllowed { - err = db. - Where("questionnaire_id = ? AND user_traqid = ?", questionnaireID, userID). - First(&Respondents{}).Error - if err == nil { + result := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "questionnaire_id"}, {Name: "user_traqid"}}, + DoNothing: true, + }).Create(&respondent) + if result.Error != nil { + return 0, fmt.Errorf("failed to insert a respondent record: %w", result.Error) + } + if result.RowsAffected == 0 { return 0, ErrDuplicatedAnswered } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return 0, fmt.Errorf("failed to check duplicate answer: %w", err) + } else { + err = db.Create(&respondent).Error + if err != nil { + return 0, fmt.Errorf("failed to insert a respondent record: %w", err) } - - } - - err = db.Create(&respondent).Error - if err != nil { - return 0, fmt.Errorf("failed to insert a respondent record: %w", err) } return respondent.ResponseID, nil From 0bd83d6611f6794533c108affdaf6b31d1749db7 Mon Sep 17 00:00:00 2001 From: Eraxyso <130852025+Eraxyso@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:51:30 +0000 Subject: [PATCH 2/4] Revert "fix: prevent race on duplicate respondent insert" This reverts commit 6c3e32b2cf4624c7dcc85e5be02d10f94fa371a4. --- model/respondents_impl.go | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/model/respondents_impl.go b/model/respondents_impl.go index 442d89ea..86be705b 100755 --- a/model/respondents_impl.go +++ b/model/respondents_impl.go @@ -12,7 +12,6 @@ import ( "gopkg.in/guregu/null.v4" "gorm.io/gorm" - "gorm.io/gorm/clause" ) // Respondent RespondentRepositoryの実装 @@ -100,21 +99,21 @@ func (*Respondent) InsertRespondent(ctx context.Context, userID string, question } if !questionnaire.IsDuplicateAnswerAllowed { - result := db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "questionnaire_id"}, {Name: "user_traqid"}}, - DoNothing: true, - }).Create(&respondent) - if result.Error != nil { - return 0, fmt.Errorf("failed to insert a respondent record: %w", result.Error) - } - if result.RowsAffected == 0 { + err = db. + Where("questionnaire_id = ? AND user_traqid = ?", questionnaireID, userID). + First(&Respondents{}).Error + if err == nil { return 0, ErrDuplicatedAnswered } - } else { - err = db.Create(&respondent).Error - if err != nil { - return 0, fmt.Errorf("failed to insert a respondent record: %w", err) + if !errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fmt.Errorf("failed to check duplicate answer: %w", err) } + + } + + err = db.Create(&respondent).Error + if err != nil { + return 0, fmt.Errorf("failed to insert a respondent record: %w", err) } return respondent.ResponseID, nil From cfd403c495839d6b10f42e1afcbb31958d3988a2 Mon Sep 17 00:00:00 2001 From: Eraxyso <130852025+Eraxyso@users.noreply.github.com> Date: Sat, 28 Mar 2026 09:51:03 +0000 Subject: [PATCH 3/4] fix: prevent race condition on duplicate respondent insert by locking questionnaire row --- model/respondents_impl.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/model/respondents_impl.go b/model/respondents_impl.go index 86be705b..c7730cd1 100755 --- a/model/respondents_impl.go +++ b/model/respondents_impl.go @@ -12,6 +12,7 @@ import ( "gopkg.in/guregu/null.v4" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // Respondent RespondentRepositoryの実装 @@ -99,6 +100,18 @@ func (*Respondent) InsertRespondent(ctx context.Context, userID string, question } if !questionnaire.IsDuplicateAnswerAllowed { + // Lock the questionnaire row to serialize concurrent insert attempts. + // SELECT ... FOR UPDATE on respondents would not lock non-existent rows, + // allowing both transactions to pass the duplicate check simultaneously. + // By locking the parent questionnaire row (which always exists), we ensure + // only one transaction can proceed through the check+insert at a time. + err = db. + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", questionnaireID). + First(&Questionnaires{}).Error + if err != nil { + return 0, fmt.Errorf("failed to lock questionnaire row: %w", err) + } err = db. Where("questionnaire_id = ? AND user_traqid = ?", questionnaireID, userID). First(&Respondents{}).Error @@ -108,7 +121,6 @@ func (*Respondent) InsertRespondent(ctx context.Context, userID string, question if !errors.Is(err, gorm.ErrRecordNotFound) { return 0, fmt.Errorf("failed to check duplicate answer: %w", err) } - } err = db.Create(&respondent).Error From 9c3754a3ec5f25ceac9e1ad8f9c46d2ed3baeb05 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 09:52:03 +0000 Subject: [PATCH 4/4] chore: regenerate generated files [skip ci] --- wire_gen.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wire_gen.go b/wire_gen.go index 26f82ff9..38bbb3be 100644 --- a/wire_gen.go +++ b/wire_gen.go @@ -1,6 +1,6 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run github.com/google/wire/cmd/wire +//go:generate go run -mod=mod github.com/google/wire/cmd/wire //go:build !wireinject // +build !wireinject @@ -34,13 +34,13 @@ func InjectAPIServer() *handler.Handler { validation := model.NewValidation() transaction := model.NewTransaction() respondent := model.NewRespondent() - apiClient := traq.NewTraqAPIClient() webhook := traq.NewWebhook() response := model.NewResponse() controllerResponse := controller.NewResponse(questionnaire, respondent, response, target, question, option, validation, scaleLabel, transaction) reminder := controller.NewReminder() controllerQuestionnaire := controller.NewQuestionnaire(questionnaire, target, targetGroup, targetUser, administrator, administratorGroup, administratorUser, question, option, scaleLabel, validation, transaction, respondent, webhook, controllerResponse, reminder) middleware := controller.NewMiddleware(administrator, respondent, question, questionnaire) + apiClient := traq.NewTraqAPIClient() handlerHandler := handler.NewHandler(controllerQuestionnaire, controllerResponse, reminder, middleware, apiClient) return handlerHandler }