-
Notifications
You must be signed in to change notification settings - Fork 1
Fix #1718: Implement pagination-based data export to prevent 502 time… #1723
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: main
Are you sure you want to change the base?
Conversation
…outs - Add paginated endpoint /data-by-teacher-paginated/:uid for large dataset downloads - Implement client-side pagination with configurable page size (default 1000 records) - Add progress bar UI to track multi-page downloads - Stream data in pages rather than building entire export in memory - Prevents memory exhaustion and proxy timeouts on large exports - Maintains backward compatibility with existing single-page endpoints - Frontend automatically uses paginated endpoint for teacher/user data downloads - Includes comprehensive error handling and progress tracking
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.
Pull request overview
This PR implements pagination-based data export to fix issue #1718, which caused 502 Proxy Error timeouts when downloading large datasets. The solution adds a new paginated API endpoint and updates the frontend to fetch data in manageable chunks rather than requesting the entire export at once.
Key Changes:
- New server-side paginated endpoint
/data-by-teacher-paginatedthat returns data in pages with metadata - Client-side pagination logic that fetches data incrementally with progress tracking UI
- Visual progress indicator showing download status and completion percentage
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 11 comments.
| File | Description |
|---|---|
| mofacts/server/methods.js | Adds new paginated API endpoint that returns history records in pages with pagination metadata |
| mofacts/client/views/experimentReporting/dataDownload.js | Implements paginated data fetching with progress tracking and TSV file generation |
| mofacts/client/views/experimentReporting/dataDownload.html | Adds progress bar UI to display download status and progress |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <span id="downloadProgressText" style="float: right;">0%</span> | ||
| </div> | ||
| <div class="progress" style="height: 25px; border-radius: 5px; background-color: #e9ecef;"> | ||
| <div id="downloadProgressBar" | ||
| class="progress-bar progress-bar-striped progress-bar-animated" | ||
| role="progressbar" | ||
| style="width: 0%; height: 100%; background-color: #28a745; border-radius: 5px;" | ||
| aria-valuenow="0" | ||
| aria-valuemin="0" | ||
| aria-valuemax="100"> | ||
| </div> | ||
| </div> | ||
| <div style="margin-top: 10px; font-size: 0.9em;"> | ||
| <span id="downloadStatusText">Loading page 1...</span> |
Copilot
AI
Dec 22, 2025
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.
Progress bar width and text content are hardcoded. The progress bar's width is set to "0%" and the text displays hardcoded values instead of binding to the reactive variables. The style attribute should use Blaze data binding like "style='width: {{downloadProgress}}%'" and the text content should use "{{downloadProgressText}}" and "{{downloadStatusText}}" to reflect the actual progress values.
| <span id="downloadProgressText" style="float: right;">0%</span> | |
| </div> | |
| <div class="progress" style="height: 25px; border-radius: 5px; background-color: #e9ecef;"> | |
| <div id="downloadProgressBar" | |
| class="progress-bar progress-bar-striped progress-bar-animated" | |
| role="progressbar" | |
| style="width: 0%; height: 100%; background-color: #28a745; border-radius: 5px;" | |
| aria-valuenow="0" | |
| aria-valuemin="0" | |
| aria-valuemax="100"> | |
| </div> | |
| </div> | |
| <div style="margin-top: 10px; font-size: 0.9em;"> | |
| <span id="downloadStatusText">Loading page 1...</span> | |
| <span id="downloadProgressText" style="float: right;">{{downloadProgressText}}</span> | |
| </div> | |
| <div class="progress" style="height: 25px; border-radius: 5px; background-color: #e9ecef;"> | |
| <div id="downloadProgressBar" | |
| class="progress-bar progress-bar-striped progress-bar-animated" | |
| role="progressbar" | |
| style="width: {{downloadProgress}}%; height: 100%; background-color: #28a745; border-radius: 5px;" | |
| aria-valuenow="{{downloadProgress}}" | |
| aria-valuemin="0" | |
| aria-valuemax="100"> | |
| </div> | |
| </div> | |
| <div style="margin-top: 10px; font-size: 0.9em;"> | |
| <span id="downloadStatusText">{{downloadStatusText}}</span> |
| try { | ||
| const teacherUserName = history.conditionTypeE?.split('/')[0]; | ||
| // Apply permission check | ||
| if(userIsAdmin || teacherUserName == requestingUserName || teacherUserName === undefined) { |
Copilot
AI
Dec 22, 2025
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.
Use strict equality operator. The comparison uses "==" instead of "===" which can lead to unexpected type coercion. Use "===" for strict equality comparison to ensure both value and type match.
| if(userIsAdmin || teacherUserName == requestingUserName || teacherUserName === undefined) { | |
| if (userIsAdmin || teacherUserName === requestingUserName || teacherUserName === undefined) { |
| const timestamp = new Date().toISOString().split('T')[0]; | ||
| filename = `mofacts_${user.username}_${timestamp}.tsv`; |
Copilot
AI
Dec 22, 2025
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.
Potential error if user is null or undefined. If Meteor.users.findOne returns null (user not found), accessing user.username will throw a TypeError. Add a check to ensure the user exists before accessing its properties, or handle the null case appropriately.
| const timestamp = new Date().toISOString().split('T')[0]; | |
| filename = `mofacts_${user.username}_${timestamp}.tsv`; | |
| const usernamePart = user && user.username ? user.username : (userId || 'unknown'); | |
| const timestamp = new Date().toISOString().split('T')[0]; | |
| filename = `mofacts_${usernamePart}_${timestamp}.tsv`; |
| }, | ||
| }); | ||
|
|
||
|
|
Copilot
AI
Dec 22, 2025
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.
Missing Router.route() wrapper. The route definition is incomplete - it should start with "Router.route('data-by-teacher-paginated', {" similar to the pattern used in the existing 'data-by-teacher' route above (line 4016).
| Router.route('data-by-teacher-paginated', { |
| const totalPages = Math.ceil(totalRecords / pageSize); | ||
|
|
||
| // Validate page number | ||
| if (page < 1 || page > totalPages && totalRecords > 0) { |
Copilot
AI
Dec 22, 2025
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.
Operator precedence issue in validation logic. The condition "page < 1 || page > totalPages && totalRecords > 0" will not work as intended due to && having higher precedence than ||. This means "page > totalPages && totalRecords > 0" is evaluated first. The intended logic should be "(page < 1 || page > totalPages) && totalRecords > 0" or consider whether totalRecords check is even needed for the page < 1 case.
| if (page < 1 || page > totalPages && totalRecords > 0) { | |
| if (page < 1 || (page > totalPages && totalRecords > 0)) { |
| const tdfNames = assignedTdfs.concat(ownedTdfs).concat(accessorTdfs); | ||
| const uniqueTdfs = tdfNames.filter((v, i, a) => a.indexOf(v) === i); | ||
|
|
||
| if (!uniqueTdfs.length > 0) { |
Copilot
AI
Dec 22, 2025
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.
Operator precedence issue. The condition "!uniqueTdfs.length > 0" is always false due to operator precedence - the "!" operator applies to uniqueTdfs.length first, then compares the boolean result to 0. This should be "!(uniqueTdfs.length > 0)" or more simply "uniqueTdfs.length === 0". Note that the same bug exists in the original endpoint at line 4055.
| if (!uniqueTdfs.length > 0) { | |
| if (uniqueTdfs.length === 0) { |
| if (!tdf) continue; | ||
|
|
||
| const stimuliSetId = tdf.stimuliSetId; | ||
| const stims = await getStimuliSetById(stimuliSetId); |
Copilot
AI
Dec 22, 2025
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.
The variable "stims" is assigned but never used in the function. Consider removing this line if the stimuli set data is not needed, or if it was intended for some processing that was left out.
| const stims = await getStimuliSetById(stimuliSetId); |
| // Collect ALL history records (with pagination applied later) | ||
| let allHistories = []; | ||
| const userIsAdmin = Roles.userIsInRole(userId, 'admin'); | ||
| const requestingUserName = Meteor.users.findOne({_id: userId}).username; | ||
|
|
||
| try { | ||
| for(let tdfName of uniqueTdfs) { | ||
| const tdf = await getTdfByFileName(tdfName); | ||
| if (!tdf) continue; | ||
|
|
||
| const stimuliSetId = tdf.stimuliSetId; | ||
| const stims = await getStimuliSetById(stimuliSetId); | ||
| const histories = await getHistoryByTDFID(tdf._id); | ||
|
|
||
| for (let history of histories) { | ||
| try { | ||
| const teacherUserName = history.conditionTypeE?.split('/')[0]; | ||
| // Apply permission check | ||
| if(userIsAdmin || teacherUserName == requestingUserName || teacherUserName === undefined) { | ||
| // Transform the history record using the standard function | ||
| const transformedHistory = getHistory(history); | ||
| allHistories.push(transformedHistory); | ||
| } | ||
| } catch (e) { | ||
| serverConsole('Error processing history record:', e); | ||
| } | ||
| } | ||
| } | ||
| } catch (e) { | ||
| serverConsole('Error collecting histories:', e); | ||
| response.writeHead(500, { 'Content-Type': 'application/json' }); | ||
| response.write(JSON.stringify({error: 'Error processing data'})); | ||
| response.end(); | ||
| return; | ||
| } | ||
|
|
||
| // Calculate pagination | ||
| const totalRecords = allHistories.length; | ||
| const totalPages = Math.ceil(totalRecords / pageSize); | ||
|
|
||
| // Validate page number | ||
| if (page < 1 || page > totalPages && totalRecords > 0) { | ||
| response.writeHead(400, { 'Content-Type': 'application/json' }); | ||
| response.write(JSON.stringify({ | ||
| error: 'Invalid page number', | ||
| totalPages: totalPages | ||
| })); | ||
| response.end(); | ||
| return; | ||
| } | ||
|
|
||
| // Get page slice | ||
| const startIdx = (page - 1) * pageSize; | ||
| const endIdx = Math.min(startIdx + pageSize, totalRecords); | ||
| const pageRecords = allHistories.slice(startIdx, endIdx); |
Copilot
AI
Dec 22, 2025
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.
Performance issue: pagination defeats its purpose. The implementation fetches ALL history records from the database (lines 4131-4158) into memory before applying pagination. This means for large datasets, the server will still experience the same memory and timeout issues that pagination was meant to solve. The pagination should be applied at the database query level, not in memory after fetching all records. Consider using MongoDB's skip() and limit() methods or implementing cursor-based pagination to truly solve the 502 timeout issue.
| }); | ||
| }); | ||
|
|
||
| const [uid, authToken] = credentials; |
Copilot
AI
Dec 22, 2025
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.
Unused variable uid.
| const [uid, authToken] = credentials; | |
| const [, authToken] = credentials; |
| let allRecords = []; | ||
| let page = 1; | ||
| let hasMore = true; | ||
| let totalPages = 1; |
Copilot
AI
Dec 22, 2025
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.
The initial value of totalPages is unused, since it is always overwritten.
| let totalPages = 1; | |
| let totalPages; |
Issue
#1718 - 502 Proxy Error on Data Download: Implement Pagination to Prevent Timeouts
Description
Fixes recurring 502 Proxy Error when downloading large datasets by implementing a paginated API endpoint and updating the frontend to fetch data in manageable chunks instead of requesting the entire export at once.
Fixes #1718