-
Notifications
You must be signed in to change notification settings - Fork 361
fix(optimiser): retry and parallelise module data fetches to fix intermittent timeouts #4459
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import ( | |
| "sort" | ||
| "strconv" | ||
| "strings" | ||
| "sync" | ||
|
|
||
| client "github.com/nusmodifications/nusmods/website/api/optimiser/_client" | ||
| constants "github.com/nusmodifications/nusmods/website/api/optimiser/_constants" | ||
|
|
@@ -36,72 +37,118 @@ func GetAllModuleSlots( | |
|
|
||
| // These are default or backup slots for the partial timetable so that we can display some random slot for unallocated lessons | ||
| defaultSlots := make(models.ModuleDefaultSlotsMap) | ||
| for _, module := range optimiserRequest.Modules { | ||
|
|
||
| body, err := client.GetModuleData(optimiserRequest.AcadYear, strings.ToUpper(module)) | ||
| if err != nil { | ||
| return nil, nil, nil, err | ||
| } | ||
| // Fetch and process each module concurrently. The modules are independent of | ||
| // one another, so a single slow fetch no longer blocks the others; the total | ||
| // wall-clock time is bounded by the slowest module rather than their sum. | ||
| // Results are written into per-index slots to avoid concurrent map writes, | ||
| // then assembled into the maps once all goroutines have finished. | ||
| type moduleResult struct { | ||
| merged map[models.LessonType]map[models.ClassNo][]models.ModuleSlot | ||
| defaults map[models.LessonType][]models.ModuleSlot | ||
| err error | ||
| } | ||
| results := make([]moduleResult, len(optimiserRequest.Modules)) | ||
|
|
||
| var wg sync.WaitGroup | ||
| for i, module := range optimiserRequest.Modules { | ||
| wg.Add(1) | ||
| go func(i int, module string) { | ||
| defer wg.Done() | ||
| merged, defaults, err := processModule( | ||
| optimiserRequest, | ||
| module, | ||
| venues, | ||
| recordingsMap, | ||
| freeDaysMap, | ||
| ) | ||
| results[i] = moduleResult{merged: merged, defaults: defaults, err: err} | ||
| }(i, module) | ||
| } | ||
| wg.Wait() | ||
|
Comment on lines
+53
to
+68
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.
The pull request changes the module processing logic in An unauthenticated attacker can submit a request with an arbitrarily large number of modules (e.g., thousands of duplicate or unique module codes). The backend will attempt to spawn a goroutine and initiate an outbound HTTP request to the NUSMods API for each module concurrently. This unbounded concurrency can quickly exhaust system resources, specifically:
Steps to ReproduceAn attacker can send a POST request to the optimiser endpoint with a payload containing a large number of module codes: {
"modules": ["CS1010S", "CS1010S", ... (repeated 5000 times) ...],
"recordings": [],
"freeDays": [],
"earliestTime": "0800",
"latestTime": "1800",
"acadYear": "2024-2025",
"acadSem": 1,
"maxConsecutiveHours": 4,
"lunchStart": "1200",
"lunchEnd": "1300"
}This will trigger 5,000 concurrent goroutines and outbound HTTP requests, leading to socket exhaustion or serverless function termination. Fix with AITriage: Reply |
||
|
|
||
| var moduleData struct { | ||
| SemesterData []struct { | ||
| Semester int `json:"semester"` | ||
| Timetable []models.ModuleSlot `json:"timetable"` | ||
| } `json:"semesterData"` | ||
| } | ||
| err = json.Unmarshal(body, &moduleData) | ||
| if err != nil { | ||
| return nil, nil, nil, err | ||
| for i, module := range optimiserRequest.Modules { | ||
| if results[i].err != nil { | ||
| return nil, nil, nil, results[i].err | ||
|
Comment on lines
+68
to
+72
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.
When one module has a permanent error such as a 404, the request still waits for every already-started module goroutine to finish before returning that error. Mixed valid and invalid requests now make unnecessary external calls and can wait on unrelated slow retries, whereas the old sequential path stopped at the first failing module. Prompt To Fix With AIThis is a comment left during a code review.
Path: website/api/optimiser/_modules/modules.go
Line: 68-72
Comment:
**Terminal Errors Wait For All Fetches**
When one module has a permanent error such as a 404, the request still waits for every already-started module goroutine to finish before returning that error. Mixed valid and invalid requests now make unnecessary external calls and can wait on unrelated slow retries, whereas the old sequential path stopped at the first failing module.
How can I resolve this? If you propose a fix, please make it concise. |
||
| } | ||
| moduleSlots[module] = results[i].merged | ||
| defaultSlots[module] = results[i].defaults | ||
| } | ||
|
|
||
| // Get the module timetable for the semester | ||
| var moduleTimetable []models.ModuleSlot | ||
| for _, semester := range moduleData.SemesterData { | ||
| if semester.Semester == optimiserRequest.AcadSem { | ||
| moduleTimetable = semester.Timetable | ||
| break | ||
| } | ||
| } | ||
| return moduleSlots, defaultSlots, recordingsMap, nil | ||
| } | ||
|
|
||
| // processModule fetches a single module's data and reduces it to its candidate | ||
| // and default slots. It is safe to run concurrently for different modules since | ||
| // it only reads the shared venues/recordings/freeDays maps and returns its | ||
| // result rather than writing to shared state. | ||
| func processModule( | ||
| optimiserRequest *models.OptimiserRequest, | ||
| module string, | ||
| venues map[string]models.Location, | ||
| recordingsMap map[string]struct{}, | ||
| freeDaysMap map[string]struct{}, | ||
| ) (map[models.LessonType]map[models.ClassNo][]models.ModuleSlot, map[models.LessonType][]models.ModuleSlot, error) { | ||
| body, err := client.GetModuleData(optimiserRequest.AcadYear, strings.ToUpper(module)) | ||
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
|
|
||
| // Parse the weeks | ||
| for i := range moduleTimetable { | ||
| var moduleData struct { | ||
| SemesterData []struct { | ||
| Semester int `json:"semester"` | ||
| Timetable []models.ModuleSlot `json:"timetable"` | ||
| } `json:"semesterData"` | ||
| } | ||
| err = json.Unmarshal(body, &moduleData) | ||
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
|
|
||
| // Note: if weeks is not a []int, then skip parsing | ||
| // Currently we are not handling week conflict for non-[]int weeks | ||
| if _, ok := moduleTimetable[i].Weeks.([]any); !ok { | ||
| continue | ||
| } | ||
| // Get the module timetable for the semester | ||
| var moduleTimetable []models.ModuleSlot | ||
| for _, semester := range moduleData.SemesterData { | ||
| if semester.Semester == optimiserRequest.AcadSem { | ||
| moduleTimetable = semester.Timetable | ||
| break | ||
| } | ||
| } | ||
|
|
||
| moduleTimetable[i].WeeksSet = make(map[int]struct{}) | ||
| weeks := moduleTimetable[i].Weeks.([]any) | ||
| weeksStrings := make([]string, 0, len(weeks)) | ||
| // Parse the weeks | ||
| for i := range moduleTimetable { | ||
|
|
||
| for _, week := range weeks { | ||
| weekFloat, ok := week.(float64) | ||
| if !ok { | ||
| continue | ||
| } | ||
| weekInt := int(weekFloat) | ||
| moduleTimetable[i].WeeksSet[weekInt] = struct{}{} | ||
| weeksStrings = append(weeksStrings, strconv.Itoa(weekInt)) | ||
| } | ||
| moduleTimetable[i].WeeksString = strings.Join(weeksStrings, ",") | ||
| // Note: if weeks is not a []int, then skip parsing | ||
| // Currently we are not handling week conflict for non-[]int weeks | ||
| if _, ok := moduleTimetable[i].Weeks.([]any); !ok { | ||
| continue | ||
| } | ||
|
|
||
| // Store the module slots for the module | ||
| moduleSlots[module], defaultSlots[module] = mergeAndFilterModuleSlots( | ||
| moduleTimetable, | ||
| venues, | ||
| module, | ||
| recordingsMap, | ||
| freeDaysMap, | ||
| optimiserRequest.EarliestMin, | ||
| optimiserRequest.LatestMin, | ||
| ) | ||
| moduleTimetable[i].WeeksSet = make(map[int]struct{}) | ||
| weeks := moduleTimetable[i].Weeks.([]any) | ||
| weeksStrings := make([]string, 0, len(weeks)) | ||
|
|
||
| for _, week := range weeks { | ||
| weekFloat, ok := week.(float64) | ||
| if !ok { | ||
| continue | ||
| } | ||
| weekInt := int(weekFloat) | ||
| moduleTimetable[i].WeeksSet[weekInt] = struct{}{} | ||
| weeksStrings = append(weeksStrings, strconv.Itoa(weekInt)) | ||
| } | ||
| moduleTimetable[i].WeeksString = strings.Join(weeksStrings, ",") | ||
| } | ||
|
|
||
| return moduleSlots, defaultSlots, recordingsMap, nil | ||
| merged, defaults := mergeAndFilterModuleSlots( | ||
| moduleTimetable, | ||
| venues, | ||
| module, | ||
| recordingsMap, | ||
| freeDaysMap, | ||
| optimiserRequest.EarliestMin, | ||
| optimiserRequest.LatestMin, | ||
| ) | ||
| return merged, defaults, nil | ||
| } | ||
|
|
||
| // Gets all venue information from venues.json | ||
|
|
||
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.
When a request contains many modules, this loop starts one goroutine per module and each goroutine can make up to three outbound API attempts. A single oversized optimiser request can now create a burst of simultaneous HTTP calls, which can exhaust local resources or trigger upstream throttling instead of keeping outbound pressure bounded as the old sequential path did.
Prompt To Fix With AI
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.
@TheMythologist I think this is a valid point also considering that we are working with not very powerful cpus in Vercel. Perhaps limiting it to some number of goroutines (ex: 4) using a Semaphore?