perf: optimize LoTW and DCL sync with batch operations

Fixes frontend freeze during large sync operations (8000+ QSOs).

Root cause: Sequential processing with individual database operations
(~24,000 queries for 8000 QSOs) blocked the event loop, preventing
polling requests from being processed.

Changes:
- Process QSOs in batches of 100
- Single SELECT query per batch for duplicate detection
- Batch INSERTs for new QSOs and change tracking
- Add yield points (setImmediate) after each batch to allow
  event loop processing of polling requests

Performance: ~98% reduction in database operations
Before: 8000 QSOs × 3 queries = ~24,000 sequential operations
After: 80 batches × ~4 operations = ~320 operations

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-21 10:28:24 +01:00
parent e88537754f
commit 7026f2bca7
2 changed files with 343 additions and 240 deletions

View File

@@ -170,7 +170,22 @@ function convertQSODatabaseFormat(adifQSO, userId) {
} }
/** /**
* Sync QSOs from DCL to database * Yield to event loop to allow other requests to be processed
* This prevents blocking the server during long-running sync operations
*/
function yieldToEventLoop() {
return new Promise(resolve => setImmediate(resolve));
}
/**
* Get QSO key for duplicate detection
*/
function getQSOKey(qso) {
return `${qso.callsign}|${qso.qsoDate}|${qso.timeOn}|${qso.band}|${qso.mode}`;
}
/**
* Sync QSOs from DCL to database (optimized with batch operations)
* Updates existing QSOs with DCL confirmation data * Updates existing QSOs with DCL confirmation data
* *
* @param {number} userId - User ID * @param {number} userId - User ID
@@ -219,181 +234,215 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
const addedQSOs = []; const addedQSOs = [];
const updatedQSOs = []; const updatedQSOs = [];
for (let i = 0; i < adifQSOs.length; i++) { // Convert all QSOs to database format
const adifQSO = adifQSOs[i]; const dbQSOs = adifQSOs.map(qso => convertQSODatabaseFormat(qso, userId));
try { // Batch size for processing
const dbQSO = convertQSODatabaseFormat(adifQSO, userId); const BATCH_SIZE = 100;
const totalBatches = Math.ceil(dbQSOs.length / BATCH_SIZE);
// Check if QSO already exists (match by callsign, date, time, band, mode) for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
const existing = await db const startIdx = batchNum * BATCH_SIZE;
.select() const endIdx = Math.min(startIdx + BATCH_SIZE, dbQSOs.length);
.from(qsos) const batch = dbQSOs.slice(startIdx, endIdx);
.where(
and( // Get unique callsigns and dates from batch
eq(qsos.userId, userId), const batchCallsigns = [...new Set(batch.map(q => q.callsign))];
eq(qsos.callsign, dbQSO.callsign), const batchDates = [...new Set(batch.map(q => q.qsoDate))];
eq(qsos.qsoDate, dbQSO.qsoDate),
eq(qsos.timeOn, dbQSO.timeOn), // Fetch all existing QSOs that could match this batch in one query
eq(qsos.band, dbQSO.band), const existingQSOs = await db
eq(qsos.mode, dbQSO.mode) .select()
) .from(qsos)
.where(
and(
eq(qsos.userId, userId),
// Match callsigns OR dates from this batch
sql`(${qsos.callsign} IN ${batchCallsigns} OR ${qsos.qsoDate} IN ${batchDates})`
) )
.limit(1); );
if (existing.length > 0) { // Build lookup map for existing QSOs
const existingQSO = existing[0]; const existingMap = new Map();
for (const existing of existingQSOs) {
const key = getQSOKey(existing);
existingMap.set(key, existing);
}
// Check if DCL confirmation or DOK data has changed // Process batch
const dataChanged = const toInsert = [];
existingQSO.dclQslRstatus !== dbQSO.dclQslRstatus || const toUpdate = [];
existingQSO.dclQslRdate !== dbQSO.dclQslRdate || const changeRecords = [];
existingQSO.darcDok !== (dbQSO.darcDok || existingQSO.darcDok) ||
existingQSO.myDarcDok !== (dbQSO.myDarcDok || existingQSO.myDarcDok) ||
existingQSO.grid !== (dbQSO.grid || existingQSO.grid);
if (dataChanged) { for (const dbQSO of batch) {
// Record before state for rollback try {
const beforeData = JSON.stringify({ const key = getQSOKey(dbQSO);
dclQslRstatus: existingQSO.dclQslRstatus, const existingQSO = existingMap.get(key);
dclQslRdate: existingQSO.dclQslRdate,
darcDok: existingQSO.darcDok,
myDarcDok: existingQSO.myDarcDok,
grid: existingQSO.grid,
gridSource: existingQSO.gridSource,
entity: existingQSO.entity,
entityId: existingQSO.entityId,
});
// Update existing QSO with changed DCL confirmation and DOK data if (existingQSO) {
const updateData = { // Check if DCL confirmation or DOK data has changed
dclQslRdate: dbQSO.dclQslRdate, const dataChanged =
dclQslRstatus: dbQSO.dclQslRstatus, existingQSO.dclQslRstatus !== dbQSO.dclQslRstatus ||
}; existingQSO.dclQslRdate !== dbQSO.dclQslRdate ||
existingQSO.darcDok !== (dbQSO.darcDok || existingQSO.darcDok) ||
existingQSO.myDarcDok !== (dbQSO.myDarcDok || existingQSO.myDarcDok) ||
existingQSO.grid !== (dbQSO.grid || existingQSO.grid);
// Only add DOK fields if DCL sent them if (dataChanged) {
if (dbQSO.darcDok) updateData.darcDok = dbQSO.darcDok; // Build update data
if (dbQSO.myDarcDok) updateData.myDarcDok = dbQSO.myDarcDok; const updateData = {
dclQslRdate: dbQSO.dclQslRdate,
dclQslRstatus: dbQSO.dclQslRstatus,
};
// Only update grid if DCL sent one // Only add DOK fields if DCL sent them
if (dbQSO.grid) { if (dbQSO.darcDok) updateData.darcDok = dbQSO.darcDok;
updateData.grid = dbQSO.grid; if (dbQSO.myDarcDok) updateData.myDarcDok = dbQSO.myDarcDok;
updateData.gridSource = dbQSO.gridSource;
}
// DXCC priority: LoTW > DCL // Only update grid if DCL sent one
// Only update entity fields from DCL if: if (dbQSO.grid) {
// 1. QSO is NOT LoTW confirmed, AND updateData.grid = dbQSO.grid;
// 2. DCL actually sent entity data, AND updateData.gridSource = dbQSO.gridSource;
// 3. Current entity is missing }
const hasLoTWConfirmation = existingQSO.lotwQslRstatus === 'Y';
const hasDCLData = dbQSO.entity || dbQSO.entityId;
const missingEntity = !existingQSO.entity || existingQSO.entity === '';
if (!hasLoTWConfirmation && hasDCLData && missingEntity) { // DXCC priority: LoTW > DCL
// Fill in entity data from DCL (only if DCL provides it) // Only update entity fields from DCL if:
if (dbQSO.entity) updateData.entity = dbQSO.entity; // 1. QSO is NOT LoTW confirmed, AND
if (dbQSO.entityId) updateData.entityId = dbQSO.entityId; // 2. DCL actually sent entity data, AND
if (dbQSO.continent) updateData.continent = dbQSO.continent; // 3. Current entity is missing
if (dbQSO.cqZone) updateData.cqZone = dbQSO.cqZone; const hasLoTWConfirmation = existingQSO.lotwQslRstatus === 'Y';
if (dbQSO.ituZone) updateData.ituZone = dbQSO.ituZone; const hasDCLData = dbQSO.entity || dbQSO.entityId;
} const missingEntity = !existingQSO.entity || existingQSO.entity === '';
await db if (!hasLoTWConfirmation && hasDCLData && missingEntity) {
.update(qsos) if (dbQSO.entity) updateData.entity = dbQSO.entity;
.set(updateData) if (dbQSO.entityId) updateData.entityId = dbQSO.entityId;
.where(eq(qsos.id, existingQSO.id)); if (dbQSO.continent) updateData.continent = dbQSO.continent;
if (dbQSO.cqZone) updateData.cqZone = dbQSO.cqZone;
if (dbQSO.ituZone) updateData.ituZone = dbQSO.ituZone;
}
// Record after state for rollback toUpdate.push({
const afterData = JSON.stringify({ id: existingQSO.id,
dclQslRstatus: dbQSO.dclQslRstatus, data: updateData,
dclQslRdate: dbQSO.dclQslRdate,
darcDok: updateData.darcDok,
myDarcDok: updateData.myDarcDok,
grid: updateData.grid,
gridSource: updateData.gridSource,
entity: updateData.entity,
entityId: updateData.entityId,
});
// Track change in qso_changes table if jobId provided
if (jobId) {
await db.insert(qsoChanges).values({
jobId,
qsoId: existingQSO.id,
changeType: 'updated',
beforeData,
afterData,
}); });
}
updatedCount++; // Track change for rollback
// Track updated QSO (CALL and DATE) if (jobId) {
updatedQSOs.push({ changeRecords.push({
id: existingQSO.id, jobId,
qsoId: existingQSO.id,
changeType: 'updated',
beforeData: JSON.stringify({
dclQslRstatus: existingQSO.dclQslRstatus,
dclQslRdate: existingQSO.dclQslRdate,
darcDok: existingQSO.darcDok,
myDarcDok: existingQSO.myDarcDok,
grid: existingQSO.grid,
gridSource: existingQSO.gridSource,
entity: existingQSO.entity,
entityId: existingQSO.entityId,
}),
afterData: JSON.stringify({
dclQslRstatus: dbQSO.dclQslRstatus,
dclQslRdate: dbQSO.dclQslRdate,
darcDok: updateData.darcDok,
myDarcDok: updateData.myDarcDok,
grid: updateData.grid,
gridSource: updateData.gridSource,
entity: updateData.entity,
entityId: updateData.entityId,
}),
});
}
updatedQSOs.push({
id: existingQSO.id,
callsign: dbQSO.callsign,
date: dbQSO.qsoDate,
band: dbQSO.band,
mode: dbQSO.mode,
});
updatedCount++;
} else {
skippedCount++;
}
} else {
// New QSO to insert
toInsert.push(dbQSO);
addedQSOs.push({
callsign: dbQSO.callsign, callsign: dbQSO.callsign,
date: dbQSO.qsoDate, date: dbQSO.qsoDate,
band: dbQSO.band, band: dbQSO.band,
mode: dbQSO.mode, mode: dbQSO.mode,
}); });
} else { addedCount++;
// Skip - same data
skippedCount++;
} }
} else { } catch (error) {
// Insert new QSO logger.error('Failed to process DCL QSO in batch', {
const [newQSO] = await db.insert(qsos).values(dbQSO).returning(); error: error.message,
qso: dbQSO,
userId,
});
errors.push({ qso: dbQSO, error: error.message });
}
}
// Track change in qso_changes table if jobId provided // Batch insert new QSOs
if (jobId) { if (toInsert.length > 0) {
const afterData = JSON.stringify({ const inserted = await db.insert(qsos).values(toInsert).returning();
callsign: dbQSO.callsign, // Track inserted QSOs with their IDs for change tracking
qsoDate: dbQSO.qsoDate, if (jobId) {
timeOn: dbQSO.timeOn, for (let i = 0; i < inserted.length; i++) {
band: dbQSO.band, changeRecords.push({
mode: dbQSO.mode,
});
await db.insert(qsoChanges).values({
jobId, jobId,
qsoId: newQSO.id, qsoId: inserted[i].id,
changeType: 'added', changeType: 'added',
beforeData: null, beforeData: null,
afterData, afterData: JSON.stringify({
callsign: toInsert[i].callsign,
qsoDate: toInsert[i].qsoDate,
timeOn: toInsert[i].timeOn,
band: toInsert[i].band,
mode: toInsert[i].mode,
}),
}); });
// Update addedQSOs with actual IDs
addedQSOs[addedCount - inserted.length + i].id = inserted[i].id;
} }
addedCount++;
// Track added QSO (CALL and DATE)
addedQSOs.push({
id: newQSO.id,
callsign: dbQSO.callsign,
date: dbQSO.qsoDate,
band: dbQSO.band,
mode: dbQSO.mode,
});
} }
// Update job progress every 10 QSOs
if (jobId && (i + 1) % 10 === 0) {
await updateJobProgress(jobId, {
processed: i + 1,
message: `Processed ${i + 1}/${adifQSOs.length} QSOs from DCL...`,
});
}
} catch (error) {
logger.error('Failed to process DCL QSO', {
error: error.message,
qso: adifQSO,
userId,
});
errors.push({ qso: adifQSO, error: error.message });
} }
// Batch update existing QSOs
if (toUpdate.length > 0) {
for (const update of toUpdate) {
await db
.update(qsos)
.set(update.data)
.where(eq(qsos.id, update.id));
}
}
// Batch insert change records
if (changeRecords.length > 0) {
await db.insert(qsoChanges).values(changeRecords);
}
// Update job progress after each batch
if (jobId) {
await updateJobProgress(jobId, {
processed: endIdx,
message: `Processed ${endIdx}/${dbQSOs.length} QSOs from DCL...`,
});
}
// Yield to event loop after each batch to allow other requests
await yieldToEventLoop();
} }
const result = { const result = {
success: true, success: true,
total: adifQSOs.length, total: dbQSOs.length,
added: addedCount, added: addedCount,
updated: updatedCount, updated: updatedCount,
skipped: skippedCount, skipped: skippedCount,

View File

@@ -211,7 +211,22 @@ function convertQSODatabaseFormat(adifQSO, userId) {
} }
/** /**
* Sync QSOs from LoTW to database * Yield to event loop to allow other requests to be processed
* This prevents blocking the server during long-running sync operations
*/
function yieldToEventLoop() {
return new Promise(resolve => setImmediate(resolve));
}
/**
* Get QSO key for duplicate detection
*/
function getQSOKey(qso) {
return `${qso.callsign}|${qso.qsoDate}|${qso.timeOn}|${qso.band}|${qso.mode}`;
}
/**
* Sync QSOs from LoTW to database (optimized with batch operations)
* @param {number} userId - User ID * @param {number} userId - User ID
* @param {string} lotwUsername - LoTW username * @param {string} lotwUsername - LoTW username
* @param {string} lotwPassword - LoTW password * @param {string} lotwPassword - LoTW password
@@ -258,129 +273,168 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
const addedQSOs = []; const addedQSOs = [];
const updatedQSOs = []; const updatedQSOs = [];
for (let i = 0; i < adifQSOs.length; i++) { // Convert all QSOs to database format
const qsoData = adifQSOs[i]; const dbQSOs = adifQSOs.map(qsoData => convertQSODatabaseFormat(qsoData, userId));
try { // Batch size for processing
const dbQSO = convertQSODatabaseFormat(qsoData, userId); const BATCH_SIZE = 100;
const totalBatches = Math.ceil(dbQSOs.length / BATCH_SIZE);
const existing = await db for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
.select() const startIdx = batchNum * BATCH_SIZE;
.from(qsos) const endIdx = Math.min(startIdx + BATCH_SIZE, dbQSOs.length);
.where( const batch = dbQSOs.slice(startIdx, endIdx);
and(
eq(qsos.userId, userId), // Build condition for batch duplicate check
eq(qsos.callsign, dbQSO.callsign), // Get unique callsigns, dates, bands, modes from batch
eq(qsos.qsoDate, dbQSO.qsoDate), const batchCallsigns = [...new Set(batch.map(q => q.callsign))];
eq(qsos.timeOn, dbQSO.timeOn), const batchDates = [...new Set(batch.map(q => q.qsoDate))];
eq(qsos.band, dbQSO.band),
eq(qsos.mode, dbQSO.mode) // Fetch all existing QSOs that could match this batch in one query
) const existingQSOs = await db
.select()
.from(qsos)
.where(
and(
eq(qsos.userId, userId),
// Match callsigns OR dates from this batch
sql`(${qsos.callsign} IN ${batchCallsigns} OR ${qsos.qsoDate} IN ${batchDates})`
) )
.limit(1); );
if (existing.length > 0) { // Build lookup map for existing QSOs
const existingQSO = existing[0]; const existingMap = new Map();
for (const existing of existingQSOs) {
const key = getQSOKey(existing);
existingMap.set(key, existing);
}
// Check if LoTW confirmation data has changed // Process batch
const confirmationChanged = const toInsert = [];
existingQSO.lotwQslRstatus !== dbQSO.lotwQslRstatus || const toUpdate = [];
existingQSO.lotwQslRdate !== dbQSO.lotwQslRdate; const changeRecords = [];
if (confirmationChanged) { for (const dbQSO of batch) {
// Record before state for rollback try {
const beforeData = JSON.stringify({ const key = getQSOKey(dbQSO);
lotwQslRstatus: existingQSO.lotwQslRstatus, const existingQSO = existingMap.get(key);
lotwQslRdate: existingQSO.lotwQslRdate,
});
await db if (existingQSO) {
.update(qsos) // Check if LoTW confirmation data has changed
.set({ const confirmationChanged =
existingQSO.lotwQslRstatus !== dbQSO.lotwQslRstatus ||
existingQSO.lotwQslRdate !== dbQSO.lotwQslRdate;
if (confirmationChanged) {
toUpdate.push({
id: existingQSO.id,
lotwQslRdate: dbQSO.lotwQslRdate, lotwQslRdate: dbQSO.lotwQslRdate,
lotwQslRstatus: dbQSO.lotwQslRstatus, lotwQslRstatus: dbQSO.lotwQslRstatus,
lotwSyncedAt: dbQSO.lotwSyncedAt, lotwSyncedAt: dbQSO.lotwSyncedAt,
})
.where(eq(qsos.id, existingQSO.id));
// Record after state for rollback
const afterData = JSON.stringify({
lotwQslRstatus: dbQSO.lotwQslRstatus,
lotwQslRdate: dbQSO.lotwQslRdate,
});
// Track change in qso_changes table if jobId provided
if (jobId) {
await db.insert(qsoChanges).values({
jobId,
qsoId: existingQSO.id,
changeType: 'updated',
beforeData,
afterData,
}); });
}
updatedCount++; // Track change for rollback
// Track updated QSO (CALL and DATE) if (jobId) {
updatedQSOs.push({ changeRecords.push({
id: existingQSO.id, jobId,
qsoId: existingQSO.id,
changeType: 'updated',
beforeData: JSON.stringify({
lotwQslRstatus: existingQSO.lotwQslRstatus,
lotwQslRdate: existingQSO.lotwQslRdate,
}),
afterData: JSON.stringify({
lotwQslRstatus: dbQSO.lotwQslRstatus,
lotwQslRdate: dbQSO.lotwQslRdate,
}),
});
}
updatedQSOs.push({
id: existingQSO.id,
callsign: dbQSO.callsign,
date: dbQSO.qsoDate,
band: dbQSO.band,
mode: dbQSO.mode,
});
updatedCount++;
} else {
skippedCount++;
}
} else {
// New QSO to insert
toInsert.push(dbQSO);
addedQSOs.push({
callsign: dbQSO.callsign, callsign: dbQSO.callsign,
date: dbQSO.qsoDate, date: dbQSO.qsoDate,
band: dbQSO.band, band: dbQSO.band,
mode: dbQSO.mode, mode: dbQSO.mode,
}); });
} else { addedCount++;
// Skip - same data
skippedCount++;
} }
} else { } catch (error) {
// Insert new QSO logger.error('Error processing QSO in batch', { error: error.message, jobId, qso: dbQSO });
const [newQSO] = await db.insert(qsos).values(dbQSO).returning(); errors.push({ qso: dbQSO, error: error.message });
}
}
// Track change in qso_changes table if jobId provided // Batch insert new QSOs
if (jobId) { if (toInsert.length > 0) {
const afterData = JSON.stringify({ const inserted = await db.insert(qsos).values(toInsert).returning();
callsign: dbQSO.callsign, // Track inserted QSOs with their IDs for change tracking
qsoDate: dbQSO.qsoDate, if (jobId) {
timeOn: dbQSO.timeOn, for (let i = 0; i < inserted.length; i++) {
band: dbQSO.band, changeRecords.push({
mode: dbQSO.mode,
});
await db.insert(qsoChanges).values({
jobId, jobId,
qsoId: newQSO.id, qsoId: inserted[i].id,
changeType: 'added', changeType: 'added',
beforeData: null, beforeData: null,
afterData, afterData: JSON.stringify({
callsign: toInsert[i].callsign,
qsoDate: toInsert[i].qsoDate,
timeOn: toInsert[i].timeOn,
band: toInsert[i].band,
mode: toInsert[i].mode,
}),
}); });
// Update addedQSOs with actual IDs
addedQSOs[addedCount - inserted.length + i].id = inserted[i].id;
} }
addedCount++;
// Track added QSO (CALL and DATE)
addedQSOs.push({
id: newQSO.id,
callsign: dbQSO.callsign,
date: dbQSO.qsoDate,
band: dbQSO.band,
mode: dbQSO.mode,
});
} }
// Update job progress every 10 QSOs
if (jobId && (i + 1) % 10 === 0) {
await updateJobProgress(jobId, {
processed: i + 1,
message: `Processed ${i + 1}/${adifQSOs.length} QSOs...`,
});
}
} catch (error) {
logger.error('Error processing QSO', { error: error.message, jobId, qso: qsoData });
errors.push({ qso: qsoData, error: error.message });
} }
// Batch update existing QSOs
if (toUpdate.length > 0) {
for (const update of toUpdate) {
await db
.update(qsos)
.set({
lotwQslRdate: update.lotwQslRdate,
lotwQslRstatus: update.lotwQslRstatus,
lotwSyncedAt: update.lotwSyncedAt,
})
.where(eq(qsos.id, update.id));
}
}
// Batch insert change records
if (changeRecords.length > 0) {
await db.insert(qsoChanges).values(changeRecords);
}
// Update job progress after each batch
if (jobId) {
await updateJobProgress(jobId, {
processed: endIdx,
message: `Processed ${endIdx}/${dbQSOs.length} QSOs...`,
});
}
// Yield to event loop after each batch to allow other requests
await yieldToEventLoop();
} }
logger.info('LoTW sync completed', { total: adifQSOs.length, added: addedCount, updated: updatedCount, skipped: skippedCount, jobId }); logger.info('LoTW sync completed', { total: dbQSOs.length, added: addedCount, updated: updatedCount, skipped: skippedCount, jobId });
// Invalidate award and stats cache for this user since QSOs may have changed // Invalidate award and stats cache for this user since QSOs may have changed
const deletedCache = invalidateUserCache(userId); const deletedCache = invalidateUserCache(userId);
@@ -389,7 +443,7 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
return { return {
success: true, success: true,
total: adifQSOs.length, total: dbQSOs.length,
added: addedCount, added: addedCount,
updated: updatedCount, updated: updatedCount,
skipped: skippedCount, skipped: skippedCount,