-
Notifications
You must be signed in to change notification settings - Fork 17
fix: judge concurrency safety #329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
gsonntag
wants to merge
4
commits into
hackutd:master
Choose a base branch
from
gsonntag:fix/judge-concurrency-safety
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
96656dc
fix: preserve mongo transaction errors in UpdateAfterSeen
gsonntag 3648629
fix: make /judge/next concurrency-safe
gsonntag 232271c
fix: make /judge/finish concurrency-safe
gsonntag f3b20e8
Merge branch 'master' into fix/judge-concurrency-safety
MichaelZhao21 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ package router | |
|
|
||
| import ( | ||
| "errors" | ||
| "fmt" | ||
| "net/http" | ||
| "server/database" | ||
| "server/funcs" | ||
|
|
@@ -392,56 +393,90 @@ func GetNextJudgeProject(ctx *gin.Context) { | |
| // Get the judge from the context | ||
| judge := ctx.MustGet("judge").(*models.Judge) | ||
|
|
||
| // If the judge already has a next project, return that project | ||
| if judge.Current != nil { | ||
| ctx.JSON(http.StatusOK, gin.H{"project_id": judge.Current.Hex()}) | ||
| return | ||
| } | ||
|
|
||
| // Otherwise, get the next project for the judge | ||
| // Outputs from the transaction. Populated under either of two | ||
| // outcomes: the judge had a current project (returned as-is), or a | ||
| // new project was picked and assigned. pickedProjectName is set only | ||
| // in the new-pick case so we can log it once after the txn commits. | ||
| var pickedProjectId, pickedProjectName string | ||
|
|
||
| // All judge-state reads/writes happen inside a single transaction. | ||
| // IMPORTANT: do NOT call ctx.JSON inside the callback. Mongo's | ||
| // session.WithTransaction retries the callback on | ||
| // TransientTransactionError (which includes WriteConflict); writing | ||
| // the response on a doomed first attempt would race the retry. | ||
| err := database.WithTransaction(state.Db, func(sc mongo.SessionContext) error { | ||
| // Get options | ||
| // Reset outputs in case the callback is retried. | ||
| pickedProjectId = "" | ||
| pickedProjectName = "" | ||
|
|
||
| options, err := database.GetOptions(state.Db, sc) | ||
| if err != nil { | ||
| return errors.New("error getting options: " + err.Error()) | ||
| return fmt.Errorf("error getting options: %w", err) | ||
| } | ||
|
|
||
| // If the clock is paused, return an empty object | ||
| // This is to ensure that no projects are gotten if the clock is paused | ||
| state.Clock.Mutex.Lock() | ||
| if !state.Clock.State.Running || options.Deliberation { | ||
| // Re-read the judge inside the transaction so concurrent | ||
| // requests from the same judge see committed state. Without | ||
| // this, two simultaneous /judge/next requests both see | ||
| // judge.Current == nil from the stale middleware snapshot and | ||
| // each pick a different project, leaving one project with the | ||
| // judge in its seen list while the judge's current points at | ||
| // the other. | ||
| freshJudge, err := database.FindJudge(state.Db, sc, judge.Id) | ||
| if err != nil { | ||
| return fmt.Errorf("error finding judge in database: %w", err) | ||
| } | ||
| if freshJudge == nil { | ||
| return errors.New("judge not found in database") | ||
| } | ||
|
|
||
| // Already assigned (either before this request started or by a | ||
| // concurrent request that committed first) — surface it. | ||
| if freshJudge.Current != nil { | ||
| pickedProjectId = freshJudge.Current.Hex() | ||
| return nil | ||
| } | ||
| state.Clock.Mutex.Unlock() | ||
|
|
||
| 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()}) | ||
| // Read the clock state under its mutex, then unlock immediately | ||
| // so the early return path can't deadlock the clock. We only gate | ||
| // new assignments here; an already-assigned current project should | ||
| // still be returned even if judging gets paused afterward. | ||
| state.Clock.Mutex.Lock() | ||
| running := state.Clock.State.Running | ||
| state.Clock.Mutex.Unlock() | ||
| if !running || options.Deliberation { | ||
| return nil | ||
| } | ||
|
|
||
| // If there is no next project, return an empty object | ||
| project, err := judging.PickNextProject(state.Db, sc, freshJudge, state.Comps) | ||
| if err != nil { | ||
| return fmt.Errorf("error picking next project: %w", err) | ||
| } | ||
| if project == nil { | ||
| ctx.JSON(http.StatusOK, gin.H{}) | ||
| return nil | ||
| } | ||
|
|
||
| // Update judge and project | ||
| err = database.UpdateAfterPicked(state.Db, sc, project, judge) | ||
| err = database.UpdateAfterPicked(state.Db, sc, project, freshJudge) | ||
| if err != nil { | ||
| ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error updating next project in database: " + err.Error()}) | ||
| return nil | ||
| return fmt.Errorf("error updating next project in database: %w", err) | ||
| } | ||
|
|
||
| // Send OK and project ID | ||
| state.Logger.JudgeLogf(judge, "Picked new project %s (%s)", project.Name, project.Id.Hex()) | ||
| ctx.JSON(http.StatusOK, gin.H{"project_id": project.Id.Hex()}) | ||
| pickedProjectId = project.Id.Hex() | ||
| pickedProjectName = project.Name | ||
| return nil | ||
| }) | ||
| if err != nil { | ||
| ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| if pickedProjectId == "" { | ||
| ctx.JSON(http.StatusOK, gin.H{}) | ||
| return | ||
| } | ||
| if pickedProjectName != "" { | ||
| state.Logger.JudgeLogf(judge, "Picked new project %s (%s)", pickedProjectName, pickedProjectId) | ||
| } | ||
| ctx.JSON(http.StatusOK, gin.H{"project_id": pickedProjectId}) | ||
| } | ||
|
|
||
| // GET /judge/projects - Endpoint to get a list of projects that a judge has seen | ||
|
|
@@ -684,65 +719,95 @@ func JudgeFinish(ctx *gin.Context) { | |
| return | ||
| } | ||
|
|
||
| // Run remaining actions in a transaction | ||
| // finishedProjId is set inside the txn once we know which project | ||
| // the judge had assigned; empty means the request was a no-op | ||
| // (already finished by a concurrent request). | ||
| var finishedProjId string | ||
| // deliberationActive lets us distinguish a 400 (operator-disabled | ||
| // scoring) from a 500 (database failure) when we surface the error | ||
| // after the transaction. | ||
| deliberationActive := false | ||
|
|
||
| // IMPORTANT: do NOT call ctx.JSON inside the callback. Mongo's | ||
| // session.WithTransaction retries the callback on | ||
| // TransientTransactionError (which includes WriteConflict). If we | ||
| // wrote the response on a doomed first attempt, a successful retry | ||
| // would still leave the client looking at the 500. | ||
|
Comment on lines
+731
to
+735
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like we don't need to explain all of this here; perhaps just including the explanation in the PR/commit is good enough and just adding a note to not call ctx.JSON in the callback. The same comment goes for the top of the callback for /judge/next. |
||
| 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) | ||
| // Reset outputs in case the callback is retried. | ||
| finishedProjId = "" | ||
| deliberationActive = false | ||
|
|
||
| options, err := database.GetOptions(state.Db, sc) | ||
| if err != nil { | ||
| ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error getting options: " + err.Error()}) | ||
| return err | ||
| return fmt.Errorf("error getting options: %w", err) | ||
| } | ||
| if options.Deliberation { | ||
| ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot score due to deliberation mode being enabled"}) | ||
| return err | ||
| deliberationActive = true | ||
| return errors.New("cannot score due to deliberation mode being enabled") | ||
| } | ||
|
|
||
| // Get the project from the database | ||
| project, err := database.FindProject(state.Db, sc, judge.Current) | ||
| // Re-read the judge inside the transaction so concurrent | ||
| // finish requests see committed state. Without this, two | ||
| // simultaneous POST /judge/finish requests both read | ||
| // judge.Current from the stale middleware snapshot and both | ||
| // push the same project to seen_projects, producing | ||
| // duplicates. | ||
| freshJudge, err := database.FindJudge(state.Db, sc, judge.Id) | ||
| if err != nil { | ||
| ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error finding project in database: " + err.Error()}) | ||
| return err | ||
| return fmt.Errorf("error finding judge in database: %w", err) | ||
| } | ||
| if freshJudge == nil { | ||
| return errors.New("judge not found in database") | ||
| } | ||
| if freshJudge.Current == nil { | ||
| // Already processed by a concurrent request — treat as success. | ||
| return nil | ||
| } | ||
| finishedProjId = freshJudge.Current.Hex() | ||
|
|
||
| project, err := database.FindProject(state.Db, sc, freshJudge.Current) | ||
| if err != nil { | ||
| return fmt.Errorf("error finding project in database: %w", err) | ||
| } | ||
|
|
||
| // Create the judged project object | ||
| judgedProject := models.JudgeProjectFromProject(project, scoreReq.Notes, scoreReq.Starred) | ||
|
|
||
| // If groups are enabled and auto switch, move the judge to the next group conditionally | ||
| if options.MultiGroup && options.SwitchingMode == "auto" { | ||
| err = judging.MoveJudgeGroup(state.Db, sc, judge, options) | ||
| err = judging.MoveJudgeGroup(state.Db, sc, freshJudge, options) | ||
| if err != nil { | ||
| ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error moving judge group: " + err.Error()}) | ||
| return err | ||
| return fmt.Errorf("error moving judge group: %w", err) | ||
| } | ||
| } | ||
|
|
||
| // Update the judge and project | ||
| err = database.UpdateAfterSeen(state.Db, sc, judge, judgedProject) | ||
| err = database.UpdateAfterSeen(state.Db, sc, freshJudge, judgedProject) | ||
| if err != nil { | ||
| ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error storing scores in database: " + err.Error()}) | ||
| return err | ||
| } | ||
|
|
||
| // Reset list of skipped projects due to busy status | ||
| err = database.ResetBusyProjectListForJudge(state.Db, sc, judge) | ||
| err = database.ResetBusyProjectListForJudge(state.Db, sc, freshJudge) | ||
| if err != nil { | ||
| ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error resetting busy project list in database: " + err.Error()}) | ||
| return err | ||
| return fmt.Errorf("error resetting busy project list in database: %w", err) | ||
| } | ||
|
|
||
| return nil | ||
| }) | ||
| if err != nil { | ||
| if deliberationActive { | ||
| ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||
| } else { | ||
| ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| // Send OK | ||
| starred := "" | ||
| if scoreReq.Starred { | ||
| starred = " and starred project" | ||
| } | ||
| projId := judge.Current.Hex() | ||
| state.Logger.JudgeLogf(judge, "Finished judging project %s%s", projId, starred) | ||
| if finishedProjId != "" { | ||
| state.Logger.JudgeLogf(judge, "Finished judging project %s%s", finishedProjId, starred) | ||
| } | ||
| ctx.JSON(http.StatusOK, gin.H{"ok": 1}) | ||
| } | ||
|
|
||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think it's necessary to lock the clock mutex here. Because the running state is a boolean, it shouldn't need to be locked for reading.