mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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:
1 parent
5df6cec80b
commit
84d4cd3709
2 files changed
+163
-21
No files matched your search
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user