Fix(ui): dedupe MaxMind lookups across concurrent callers

The WebRTC test resolves the same public IP from several STUN servers and
each card looked it up independently — N identical /api/maxmind requests.
Lookups now share a module-level in-flight map plus a session result cache
keyed by ip+lang; null results and rejections stay retryable. Imports switch
to relative paths so the Node test runner can load the composable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Fable 5 committed 2026-07-14 21:32:48 +08:00
1 parent 5df6cec80b
commit 84d4cd3709
2 files changed
+163 -21

No files matched your search

+55 -21
View File
@@ -3,12 +3,43 @@
// Returns `{ country_code, country, org, asn }` on success. Returns `null` on
// every failure path (no MaxMind source, network error, malformed
// upstream payload) so callers don't have to disambiguate.
// Lookups are deduped module-wide by ip+lang: the WebRTC test resolves the
// same public IP from several STUN servers and each one triggers a lookup —
// they all share one request, and successful results are cached for the
// session (geo data doesn't change mid-visit). Failures are not cached, so a
// transient error stays retryable.
import { useMainStore } from '@/store';
// Relative imports (not the @ alias) keep this file importable from the Node
// test runner, same as the other spec-covered composables.
import { useMainStore } from '../store.js';
import { useI18n } from 'vue-i18n';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { transformDataFromIPapi } from '@/utils/transform-ip-data.js';
import getCountryName from '@/data/country-name.js';
import { fetchWithTimeout } from '../utils/fetch-with-timeout.js';
import { transformDataFromIPapi } from '../utils/transform-ip-data.js';
import getCountryName from '../data/country-name.js';
// key → resolved result (successes only) / key → in-flight promise.
const lookupCache = new Map();
const pendingLookups = new Map();
// Pure dedup wrapper (exported for tests): concurrent calls with the same key
// await the same doLookup() run; non-null results are cached permanently.
export const dedupedLookup = async (key, doLookup) => {
if (lookupCache.has(key)) return lookupCache.get(key);
const pending = pendingLookups.get(key);
if (pending) return pending;
const promise = (async () => {
const result = await doLookup();
if (result !== null) lookupCache.set(key, result);
return result;
})();
pendingLookups.set(key, promise);
try {
return await promise;
} finally {
pendingLookups.delete(key);
}
};
export function useMaxmind() {
const store = useMainStore();
@@ -22,23 +53,26 @@ export function useMaxmind() {
// ip-api.com style locale tag — same mapping the legacy call sites used.
const apiLang = lang === 'zh' ? 'zh-CN' : lang;
try {
const url = store.getDbUrl(source.id, ip, apiLang);
const response = await fetchWithTimeout(url);
const data = await response.json();
const ipData = transformDataFromIPapi(data, source.id, t, lang);
if (!ipData) return null;
const country_code = (ipData.country_code || '').toLowerCase();
const country = country_code
? getCountryName(ipData.country_code, lang)
: '';
// Upstream reports unknown ASN as the literal 'N/A' — normalize to ''.
const asn = ipData.asn && ipData.asn !== 'N/A' ? ipData.asn : '';
return { country_code, country, org: ipData.isp || '', asn };
} catch (error) {
console.error('useMaxmind lookup failed', error);
return null;
}
// `country` below is localized, so the cache key must carry the lang.
return dedupedLookup(`${ip}|${lang}`, async () => {
try {
const url = store.getDbUrl(source.id, ip, apiLang);
const response = await fetchWithTimeout(url);
const data = await response.json();
const ipData = transformDataFromIPapi(data, source.id, t, lang);
if (!ipData) return null;
const country_code = (ipData.country_code || '').toLowerCase();
const country = country_code
? getCountryName(ipData.country_code, lang)
: '';
// Upstream reports unknown ASN as the literal 'N/A' — normalize to ''.
const asn = ipData.asn && ipData.asn !== 'N/A' ? ipData.asn : '';
return { country_code, country, org: ipData.isp || '', asn };
} catch (error) {
console.error('useMaxmind lookup failed', error);
return null;
}
});
};
return { lookupMaxmind };
+108
View File
@@ -0,0 +1,108 @@
// Tests for the module-level lookup dedup in
// frontend/composables/use-maxmind.js: concurrent same-key calls share one
// doLookup run, successful results cache for the session, null results and
// rejections stay retryable. Each test uses its own keys — the cache is
// module-global and never reset.
//
// Importing the composable pulls in the real Pinia store, which needs the
// same globalThis stubs as store.test.js — installed before the import below.
globalThis.localStorage = {
_data: {},
getItem(k) { return this._data[k] ?? null; },
setItem(k, v) { this._data[k] = v; },
removeItem(k) { delete this._data[k]; },
clear() { this._data = {}; },
};
globalThis.window = {
location: { search: '' },
addEventListener() {},
innerWidth: 1024,
};
globalThis.document = {
addEventListener() {},
title: '',
querySelector() { return null; },
// @vue/runtime-dom (pulled in by vue-i18n) creates a <template> container
// at module scope — a bare object satisfies the import-time call.
createElement() { return {}; },
documentElement: { classList: { toggle() {} } },
};
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
const { dedupedLookup } = await import('../frontend/composables/use-maxmind.js');
describe('dedupedLookup — in-flight sharing', () => {
it('concurrent calls with the same key share one doLookup run', async () => {
let calls = 0;
let release;
const gate = new Promise((resolve) => { release = resolve; });
const doLookup = async () => {
calls++;
await gate;
return { asn: 'AS1' };
};
const first = dedupedLookup('inflight-key', doLookup);
const second = dedupedLookup('inflight-key', doLookup);
release();
const [a, b] = await Promise.all([first, second]);
assert.equal(calls, 1);
assert.deepEqual(a, { asn: 'AS1' });
assert.equal(a, b); // same shared result object
});
it('distinct keys run separate lookups', async () => {
let calls = 0;
const doLookup = async () => { calls++; return { asn: `AS${calls}` }; };
const [a, b] = await Promise.all([
dedupedLookup('distinct-key-1', doLookup),
dedupedLookup('distinct-key-2', doLookup),
]);
assert.equal(calls, 2);
assert.notEqual(a.asn, b.asn);
});
});
describe('dedupedLookup — result caching', () => {
it('caches a non-null result for later calls', async () => {
let calls = 0;
const doLookup = async () => { calls++; return { asn: 'AS64500' }; };
const first = await dedupedLookup('cached-key', doLookup);
const second = await dedupedLookup('cached-key', doLookup);
assert.equal(calls, 1);
assert.equal(second, first);
});
it('does not cache null — a failed lookup is retried', async () => {
let calls = 0;
const doLookup = async () => {
calls++;
return calls === 1 ? null : { asn: 'AS64501' };
};
assert.equal(await dedupedLookup('null-key', doLookup), null);
assert.deepEqual(await dedupedLookup('null-key', doLookup), { asn: 'AS64501' });
assert.equal(calls, 2);
});
it('does not cache a rejection — the pending slot clears and retries', async () => {
let calls = 0;
const doLookup = async () => {
calls++;
if (calls === 1) throw new Error('boom');
return { asn: 'AS64502' };
};
await assert.rejects(() => dedupedLookup('reject-key', doLookup), /boom/);
assert.deepEqual(await dedupedLookup('reject-key', doLookup), { asn: 'AS64502' });
assert.equal(calls, 2);
});
});