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:
deploy-migration
2026-07-02 14:59:33 +00:00
parent 1d9562105c
commit 9aa6db5b58
+100 -83
View File
@@ -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 => {
@@ -2397,6 +2452,7 @@ app.get('/api/search/:nickname', (req, res) => {
});
});
});
});
// Guard debug endpoints - only allow from localhost
const debugGuard = (req, res, next) => {
@@ -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({});
}
});
});
});