sync server.js from production (roster-vs-game-history freshness comparison for squadron identity, replacing the older per-request fallback approach)
This commit is contained in:
@@ -138,6 +138,8 @@ const SQUADRON_CACHE_TTL = 30 * 60 * 1000;
|
||||
let hasSquadronColumn = false;
|
||||
let nickLookupCache = null;
|
||||
let nickLookupCacheTime = 0;
|
||||
let gameHistoryLookupCache = null;
|
||||
let gameHistoryLookupCacheTime = 0;
|
||||
const performanceBenchmarkCache = new Map();
|
||||
const performanceBenchmarkInFlight = new Map();
|
||||
const PERFORMANCE_BENCHMARK_CACHE_TTL = 60 * 60 * 1000;
|
||||
@@ -535,7 +537,7 @@ function loadNickLookupCached(callback) {
|
||||
}
|
||||
|
||||
squadronsDb.all(`
|
||||
SELECT sm.uid, sm.nick, sm.clan_id AS sm_clan_id, sd.clan_id AS sd_clan_id, sd.tag_name, sd.short_name
|
||||
SELECT sm.uid, sm.nick, sm.clan_id AS sm_clan_id, sm.updated_at, sd.clan_id AS sd_clan_id, sd.tag_name, sd.short_name
|
||||
FROM squadron_members sm
|
||||
LEFT JOIN squadrons_data sd ON sm.clan_id = sd.clan_id
|
||||
`, [], (err, rows) => {
|
||||
@@ -545,13 +547,22 @@ function loadNickLookupCached(callback) {
|
||||
log.error('Error loading nick lookup data', err);
|
||||
nickLookupCache = {};
|
||||
} else {
|
||||
// A uid can have rows under multiple clan_ids (membership history
|
||||
// isn't always cleaned up promptly), so keep only the freshest
|
||||
// row per uid rather than letting scan order decide the winner.
|
||||
const lookup = {};
|
||||
rows.forEach(row => {
|
||||
const existing = lookup[row.uid];
|
||||
if (existing && existing.updated_at != null && row.updated_at != null
|
||||
&& existing.updated_at >= row.updated_at) {
|
||||
return;
|
||||
}
|
||||
lookup[row.uid] = {
|
||||
nick: row.nick,
|
||||
clan_id: row.sd_clan_id || row.sm_clan_id || null,
|
||||
tag_name: row.tag_name,
|
||||
short_name: row.short_name
|
||||
short_name: row.short_name,
|
||||
updated_at: row.updated_at != null ? Number(row.updated_at) : null
|
||||
};
|
||||
});
|
||||
nickLookupCache = lookup;
|
||||
@@ -564,6 +575,44 @@ function loadNickLookupCached(callback) {
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk "latest tracked game per uid" lookup, shared across leaderboard endpoints
|
||||
// so each request doesn't need its own per-uid fallback query. SQLite guarantees
|
||||
// nick/squadron_name/clan_id come from the same row as MAX(endtime_unix) here
|
||||
// since there's exactly one aggregate in the query (documented bare-column
|
||||
// behavior, not a portable SQL assumption).
|
||||
function loadGameHistoryLookupCached(callback) {
|
||||
if (gameHistoryLookupCache && Date.now() - gameHistoryLookupCacheTime < SQUADRON_CACHE_TTL) {
|
||||
return callback(gameHistoryLookupCache);
|
||||
}
|
||||
|
||||
heavyDb.all(`
|
||||
SELECT UID as uid, nick, squadron_name, clan_id, MAX(endtime_unix) as endtime_unix
|
||||
FROM player_games_hist
|
||||
WHERE nick NOT LIKE 'coop/%'
|
||||
GROUP BY UID
|
||||
`, [], (err, rows) => {
|
||||
if (err) {
|
||||
log.error('Error loading game history lookup data', err);
|
||||
gameHistoryLookupCache = gameHistoryLookupCache || {};
|
||||
} else {
|
||||
const lookup = {};
|
||||
rows.forEach(row => {
|
||||
lookup[row.uid] = {
|
||||
nick: row.nick,
|
||||
squadron_name: row.squadron_name,
|
||||
clan_id: row.clan_id != null ? Number(row.clan_id) : null,
|
||||
endtime_unix: row.endtime_unix != null ? Number(row.endtime_unix) : null
|
||||
};
|
||||
});
|
||||
gameHistoryLookupCache = lookup;
|
||||
log.info('Game history lookup cache updated', { entries: rows.length });
|
||||
}
|
||||
|
||||
gameHistoryLookupCacheTime = Date.now();
|
||||
callback(gameHistoryLookupCache);
|
||||
});
|
||||
}
|
||||
|
||||
function dbAll(query, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(query, params, (err, rows) => err ? reject(err) : resolve(rows || []));
|
||||
@@ -1172,22 +1221,30 @@ function buildCompNotation(typeCounts) {
|
||||
}
|
||||
|
||||
// Canonicalize a player's squadron identity using the squadrons_data lookup.
|
||||
// Prefers clan_id from the squadron_members cache (joined to squadrons_data); otherwise
|
||||
// falls back to a string lookup against long_name / short_name / tag_name.
|
||||
// Trusts whichever of the two signals is more recent: the roster-sync snapshot
|
||||
// (cached, from squadron_members) or the last tracked game (fallback, from
|
||||
// player_games_hist) — see resolveCurrentSquadronIdentity for why neither one
|
||||
// is reliable on its own (a roster row can outlive a squadron's sync going
|
||||
// stale; a last game can predate a squadron change by weeks).
|
||||
// Always returns the canonical tag_name + short_name for the matched clan_id when possible,
|
||||
// so consumers can group/dedupe by clan_id (or short_name) regardless of which raw value was stored.
|
||||
function resolveSquadronIdentity(cached, fallback, squadronLookup) {
|
||||
const rosterTime = cached?.updated_at != null ? Number(cached.updated_at) : null;
|
||||
const gameTime = fallback?.endtime_unix != null ? Number(fallback.endtime_unix) : null;
|
||||
const useRoster = cached?.clan_id != null
|
||||
&& (gameTime == null || (rosterTime != null && rosterTime >= gameTime));
|
||||
|
||||
let clanId = null;
|
||||
let tagName = null;
|
||||
let shortName = null;
|
||||
|
||||
if (cached && cached.clan_id) {
|
||||
if (useRoster) {
|
||||
clanId = cached.clan_id;
|
||||
tagName = cached.tag_name || null;
|
||||
shortName = cached.short_name || null;
|
||||
}
|
||||
|
||||
const rawTag = tagName || (cached ? cached.tag_name : null) || (fallback ? fallback.squadron_name : null);
|
||||
const rawTag = tagName || (useRoster ? cached.tag_name : null) || (fallback ? fallback.squadron_name : null);
|
||||
if ((!clanId || !tagName || !shortName) && rawTag && squadronLookup) {
|
||||
const sq = squadronLookup[rawTag];
|
||||
if (sq) {
|
||||
@@ -1269,9 +1326,20 @@ function cleanText(value) {
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function resolveCurrentSquadronIdentity(latestRow, squadronLookup, preferredClanId = null) {
|
||||
const effectiveClanId = preferredClanId != null
|
||||
? Number(preferredClanId)
|
||||
function resolveCurrentSquadronIdentity(latestRow, squadronLookup, rosterHint = null) {
|
||||
// Two independent, occasionally-disagreeing signals for "current squadron":
|
||||
// the roster-sync snapshot (squadron_members, via rosterHint) and the tag on
|
||||
// the player's last tracked game (player_games_hist, via latestRow). Neither
|
||||
// is reliably up to date on its own — a roster row can be an orphan left over
|
||||
// from a squadron whose sync has lapsed, and the last game can predate a
|
||||
// squadron change by weeks. Trust whichever one is actually more recent.
|
||||
const rosterTime = rosterHint?.updated_at != null ? Number(rosterHint.updated_at) : null;
|
||||
const gameTime = latestRow?.endtime_unix != null ? Number(latestRow.endtime_unix) : null;
|
||||
const useRoster = rosterHint?.clan_id != null
|
||||
&& (gameTime == null || (rosterTime != null && rosterTime >= gameTime));
|
||||
|
||||
const effectiveClanId = useRoster
|
||||
? Number(rosterHint.clan_id)
|
||||
: normalizeClanId(latestRow?.clan_id);
|
||||
const lookupByClanId = effectiveClanId != null
|
||||
? squadronLookup[`__cid_${effectiveClanId}`] || squadronLookup[String(effectiveClanId)]
|
||||
@@ -1300,7 +1368,7 @@ function resolveCurrentSquadronIdentity(latestRow, squadronLookup, preferredClan
|
||||
async function loadPlayerIdentitySnapshot(uid, squadronLookup, options = {}) {
|
||||
const latestRow = options.latestRow || await dbGetAsync(
|
||||
db,
|
||||
`SELECT nick, squadron_name, clan_id
|
||||
`SELECT nick, squadron_name, clan_id, endtime_unix
|
||||
FROM player_games_hist
|
||||
WHERE UID = ? AND nick NOT LIKE 'coop/%'
|
||||
ORDER BY session_id DESC
|
||||
@@ -1309,7 +1377,7 @@ async function loadPlayerIdentitySnapshot(uid, squadronLookup, options = {}) {
|
||||
);
|
||||
if (!latestRow) return null;
|
||||
|
||||
const currentIdentity = resolveCurrentSquadronIdentity(latestRow, squadronLookup, options.preferredClanId ?? null);
|
||||
const currentIdentity = resolveCurrentSquadronIdentity(latestRow, squadronLookup, options.rosterHint ?? null);
|
||||
const [nickRows, squadronRows] = await Promise.all([
|
||||
dbAllAsync(
|
||||
db,
|
||||
@@ -2016,7 +2084,7 @@ app.get('/api/player/:uid', (req, res) => {
|
||||
const { start_date, end_date, season, week } = req.query;
|
||||
|
||||
const latestNickQuery = `
|
||||
SELECT nick, squadron_name, clan_id
|
||||
SELECT nick, squadron_name, clan_id, endtime_unix
|
||||
FROM player_games_hist
|
||||
WHERE UID = ? AND nick NOT LIKE 'coop/%'
|
||||
ORDER BY session_id DESC
|
||||
@@ -2060,37 +2128,23 @@ app.get('/api/player/:uid', (req, res) => {
|
||||
const lookupPromise = new Promise((resolve) => {
|
||||
loadSquadronLookupCached(resolve);
|
||||
});
|
||||
// Authoritative current-roster membership. squadron_members is updated
|
||||
// by the periodic squadron-sync, so this reflects "what squadron this
|
||||
// player belongs to right now" — beats the most-recent player_games_hist
|
||||
// row which can be stale post-rename if the player hasn't played a SQB
|
||||
// since the rename.
|
||||
// Roster-sync snapshot (squadron_members). resolveCurrentSquadronIdentity
|
||||
// weighs this against the last tracked game's timestamp rather than trusting
|
||||
// it blindly — an orphaned row from a squadron whose sync has lapsed should
|
||||
// lose to a more recent game record.
|
||||
const rosterPromise = new Promise((resolve) => {
|
||||
const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db');
|
||||
if (!fs.existsSync(squadronsDbPath)) return resolve(null);
|
||||
const sdb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => {
|
||||
if (err) return resolve(null);
|
||||
sdb.get(
|
||||
'SELECT clan_id FROM squadron_members WHERE uid = ? ORDER BY updated_at DESC LIMIT 1',
|
||||
[uid],
|
||||
(qerr, row) => {
|
||||
sdb.close();
|
||||
if (qerr || !row || row.clan_id == null) return resolve(null);
|
||||
resolve(Number(row.clan_id));
|
||||
}
|
||||
);
|
||||
});
|
||||
loadNickLookupCached((nickCache) => resolve(nickCache[uid] || null));
|
||||
});
|
||||
|
||||
Promise.all([nickPromise, statsPromise, lookupPromise, rosterPromise])
|
||||
.then(async ([nickRow, vehicleRows, squadronLookup, rosterClanId]) => {
|
||||
.then(async ([nickRow, vehicleRows, squadronLookup, rosterHint]) => {
|
||||
if (!nickRow) {
|
||||
return jsonError(res, 404, 'Player not found', { uid: normalizeUid(uid) });
|
||||
}
|
||||
|
||||
const identity = await loadPlayerIdentitySnapshot(uid, squadronLookup, {
|
||||
latestRow: nickRow,
|
||||
preferredClanId: rosterClanId,
|
||||
rosterHint,
|
||||
});
|
||||
loadPerformanceBenchmarksCached(dateFilters, (benchmarks) => {
|
||||
const vehicles = vehicleRows.map(row => {
|
||||
@@ -2330,7 +2384,7 @@ app.get('/api/search/:nickname', (req, res) => {
|
||||
`;
|
||||
|
||||
const latestNickQuery = `
|
||||
SELECT nick, squadron_name, clan_id
|
||||
SELECT nick, squadron_name, clan_id, endtime_unix
|
||||
FROM player_games_hist
|
||||
WHERE UID = ? AND nick NOT LIKE 'coop/%'
|
||||
ORDER BY session_id DESC
|
||||
@@ -2352,6 +2406,7 @@ app.get('/api/search/:nickname', (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
loadNickLookupCached((nickCache) => {
|
||||
loadSquadronLookupCached((squadronLookup) => {
|
||||
const lookups = rows.map(row => (async () => {
|
||||
const latestRow = await dbGetAsync(db, latestNickQuery, [row.UID]);
|
||||
@@ -2366,7 +2421,7 @@ app.get('/api/search/:nickname', (req, res) => {
|
||||
previous_squadron_names: [],
|
||||
};
|
||||
}
|
||||
return loadPlayerIdentitySnapshot(row.UID, squadronLookup, { latestRow });
|
||||
return loadPlayerIdentitySnapshot(row.UID, squadronLookup, { latestRow, rosterHint: nickCache[row.UID] || null });
|
||||
})());
|
||||
|
||||
Promise.all(lookups).then(results => {
|
||||
@@ -2396,6 +2451,7 @@ app.get('/api/search/:nickname', (req, res) => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Guard debug endpoints - only allow from localhost
|
||||
@@ -3609,12 +3665,12 @@ app.get('/api/leaderboard/players', (req, res) => {
|
||||
|
||||
log.info('Player leaderboard aggregation done', { rows: statsRows.length, ms: Date.now() - queryStart });
|
||||
|
||||
// Step 2: Nick/squadron lookup from squadron_members cache (instant, no heavy SQL)
|
||||
// Step 2: nick/squadron lookup — both sources are shared, TTL-cached
|
||||
// maps (no heavy SQL per request); resolveSquadronIdentity picks
|
||||
// whichever of the two is more recent per player.
|
||||
loadNickLookupCached((nickCache) => {
|
||||
loadSquadronLookupCached((squadronLookup) => {
|
||||
const uncoveredUids = statsRows.filter(r => !nickCache[r.uid]).map(r => r.uid);
|
||||
|
||||
const buildResponse = (fallbackMap) => {
|
||||
loadGameHistoryLookupCached((gameHistoryCache) => {
|
||||
const response = {
|
||||
date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week),
|
||||
timeframe: dateFilters.hasFilter ? "filtered" : "all-time",
|
||||
@@ -3628,7 +3684,7 @@ app.get('/api/leaderboard/players', (req, res) => {
|
||||
const winRate = totalBattles > 0 ? (wins / totalBattles) * 100 : 0;
|
||||
|
||||
const cached = nickCache[row.uid];
|
||||
const fb = fallbackMap[row.uid];
|
||||
const fb = gameHistoryCache[row.uid];
|
||||
const nick = cached ? cached.nick : (fb ? fb.nick : row.uid);
|
||||
const sqId = resolveSquadronIdentity(cached, fb, squadronLookup);
|
||||
|
||||
@@ -3655,32 +3711,11 @@ app.get('/api/leaderboard/players', (req, res) => {
|
||||
|
||||
log.info('Player leaderboard complete', {
|
||||
playersReturned: statsRows.length,
|
||||
cachedNicks: statsRows.length - uncoveredUids.length,
|
||||
fallbackNicks: Object.keys(fallbackMap).length,
|
||||
uncoveredNicks: uncoveredUids.length - Object.keys(fallbackMap).length,
|
||||
totalMs: Date.now() - queryStart
|
||||
});
|
||||
|
||||
resolve(response);
|
||||
};
|
||||
|
||||
// Fallback for players not in any squadron — uses (UID, endtime_unix) index
|
||||
if (uncoveredUids.length > 0) {
|
||||
const fbPlaceholders = uncoveredUids.map(() => '?').join(',');
|
||||
db.all(`
|
||||
SELECT UID as uid, nick, squadron_name, MAX(endtime_unix)
|
||||
FROM player_games_hist
|
||||
WHERE UID IN (${fbPlaceholders}) AND nick NOT LIKE 'coop/%'
|
||||
GROUP BY UID
|
||||
`, uncoveredUids, (err, fbRows) => {
|
||||
const fbMap = {};
|
||||
if (!err && fbRows) fbRows.forEach(r => { fbMap[r.uid] = r; });
|
||||
if (err) log.warn('Fallback nick lookup failed', { error: err.message });
|
||||
buildResponse(fbMap);
|
||||
});
|
||||
} else {
|
||||
buildResponse({});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3760,13 +3795,11 @@ app.get('/api/leaderboard/vehicles', (req, res) => {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
// Nick/squadron lookup from squadron_members cache (instant)
|
||||
// Nick/squadron lookup — shared, TTL-cached maps; resolveSquadronIdentity
|
||||
// picks whichever of roster/game-history is more recent per player.
|
||||
loadNickLookupCached((nickCache) => {
|
||||
loadSquadronLookupCached((squadronLookup) => {
|
||||
const uniqueUids = [...new Set(vehicleRows.map(r => r.player_uid))];
|
||||
const uncoveredUids = uniqueUids.filter(uid => !nickCache[uid]);
|
||||
|
||||
const buildResponse = (fallbackMap) => {
|
||||
loadGameHistoryLookupCached((gameHistoryCache) => {
|
||||
const response = {
|
||||
date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week),
|
||||
vehicles: vehicleRows.map(row => {
|
||||
@@ -3778,7 +3811,7 @@ app.get('/api/leaderboard/vehicles', (req, res) => {
|
||||
const winRate = battles > 0 ? (wins / battles) * 100 : 0;
|
||||
|
||||
const cached = nickCache[row.player_uid];
|
||||
const fb = fallbackMap[row.player_uid];
|
||||
const fb = gameHistoryCache[row.player_uid];
|
||||
const playerNick = cached ? cached.nick : (fb ? fb.nick : row.player_uid);
|
||||
const sqId = resolveSquadronIdentity(cached, fb, squadronLookup);
|
||||
|
||||
@@ -3805,23 +3838,7 @@ app.get('/api/leaderboard/vehicles', (req, res) => {
|
||||
|
||||
log.info('Vehicle leaderboard complete', { vehiclesReturned: vehicleRows.length, totalMs: Date.now() - queryStart });
|
||||
resolve(response);
|
||||
};
|
||||
|
||||
if (uncoveredUids.length > 0) {
|
||||
const fbPlaceholders = uncoveredUids.map(() => '?').join(',');
|
||||
db.all(`
|
||||
SELECT UID as uid, nick, squadron_name, MAX(endtime_unix)
|
||||
FROM player_games_hist
|
||||
WHERE UID IN (${fbPlaceholders}) AND nick NOT LIKE 'coop/%'
|
||||
GROUP BY UID
|
||||
`, uncoveredUids, (err, fbRows) => {
|
||||
const fbMap = {};
|
||||
if (!err && fbRows) fbRows.forEach(r => { fbMap[r.uid] = r; });
|
||||
buildResponse(fbMap);
|
||||
});
|
||||
} else {
|
||||
buildResponse({});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user