Skip to content

Conversation

@JRustyHaner
Copy link
Contributor

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

…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
Copy link

Copilot AI left a 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-paginated that 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.

Comment on lines +50 to +63
<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>
Copy link

Copilot AI Dec 22, 2025

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.

Suggested change
<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>

Copilot uses AI. Check for mistakes.
try {
const teacherUserName = history.conditionTypeE?.split('/')[0];
// Apply permission check
if(userIsAdmin || teacherUserName == requestingUserName || teacherUserName === undefined) {
Copy link

Copilot AI Dec 22, 2025

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.

Suggested change
if(userIsAdmin || teacherUserName == requestingUserName || teacherUserName === undefined) {
if (userIsAdmin || teacherUserName === requestingUserName || teacherUserName === undefined) {

Copilot uses AI. Check for mistakes.
Comment on lines +461 to +462
const timestamp = new Date().toISOString().split('T')[0];
filename = `mofacts_${user.username}_${timestamp}.tsv`;
Copy link

Copilot AI Dec 22, 2025

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.

Suggested change
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 uses AI. Check for mistakes.
},
});


Copy link

Copilot AI Dec 22, 2025

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).

Suggested change
Router.route('data-by-teacher-paginated', {

Copilot uses AI. Check for mistakes.
const totalPages = Math.ceil(totalRecords / pageSize);

// Validate page number
if (page < 1 || page > totalPages && totalRecords > 0) {
Copy link

Copilot AI Dec 22, 2025

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.

Suggested change
if (page < 1 || page > totalPages && totalRecords > 0) {
if (page < 1 || (page > totalPages && totalRecords > 0)) {

Copilot uses AI. Check for mistakes.
const tdfNames = assignedTdfs.concat(ownedTdfs).concat(accessorTdfs);
const uniqueTdfs = tdfNames.filter((v, i, a) => a.indexOf(v) === i);

if (!uniqueTdfs.length > 0) {
Copy link

Copilot AI Dec 22, 2025

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.

Suggested change
if (!uniqueTdfs.length > 0) {
if (uniqueTdfs.length === 0) {

Copilot uses AI. Check for mistakes.
if (!tdf) continue;

const stimuliSetId = tdf.stimuliSetId;
const stims = await getStimuliSetById(stimuliSetId);
Copy link

Copilot AI Dec 22, 2025

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.

Suggested change
const stims = await getStimuliSetById(stimuliSetId);

Copilot uses AI. Check for mistakes.
Comment on lines +4131 to +4185
// 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);
Copy link

Copilot AI Dec 22, 2025

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.

Copilot uses AI. Check for mistakes.
});
});

const [uid, authToken] = credentials;
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable uid.

Suggested change
const [uid, authToken] = credentials;
const [, authToken] = credentials;

Copilot uses AI. Check for mistakes.
let allRecords = [];
let page = 1;
let hasMore = true;
let totalPages = 1;
Copy link

Copilot AI Dec 22, 2025

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.

Suggested change
let totalPages = 1;
let totalPages;

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TDF Data Downloads Not Working when button is clicked

2 participants