getNodeAjaxOptions() in web/pgadmin/browser/static/js/node_ajax.js, the shared helper behind every AJAX-backed dropdown, cached responses only once they had resolved, so when many components asked for the same URL before the first response landed each of them saw an empty cache and fired its own identical GET. Every column row's Data Type dropdown mounting at once on a wide table's Columns tab therefore produced one get_types request per row: around 150 concurrent duplicates on a 150-column table. Concurrent GETs are now shared in the Axios wrapper. A module-level Map tracks requests in flight, keyed on the request axios will actually make: api.getUri() resolves the URI exactly as the request will, honouring the instance baseURL, plain-object params, URLSearchParams and any custom paramsSerializer, whilst the instance's own header configuration and the per-request headers are hashed in alongside it, so an instance built with extra headers never shares with a plain one. Params are canonicalised first so that callers passing the same values in a different order still share. The entry is removed once the request settles, success or failure, so nothing leaks and later callers fetch fresh data. Beyond params and headers there is plenty in an axios config that cannot be captured in a key and that changes either what a caller gets back or how it can be aborted: transformResponse, responseType, validateStatus, timeout, signal and so on. Rather than let those share silently, a request carrying anything outside the shareable set goes straight through to api.get(). Each caller still runs its own caching and transform on the shared response, so cached behaviour and response-shape handling are unchanged. Fixes #10155.
153 lines
4.8 KiB
JavaScript
153 lines
4.8 KiB
JavaScript
/////////////////////////////////////////////////////////////
|
|
//
|
|
// pgAdmin 4 - PostgreSQL Tools
|
|
//
|
|
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
|
|
// This software is released under the PostgreSQL Licence
|
|
//
|
|
//////////////////////////////////////////////////////////////
|
|
|
|
import MockAdapter from 'axios-mock-adapter';
|
|
import axios from 'axios';
|
|
import getApiInstance, {getInflight} from 'sources/api_instance';
|
|
|
|
describe('getInflight', ()=>{
|
|
let networkMock;
|
|
|
|
beforeEach(()=>{
|
|
networkMock = new MockAdapter(axios);
|
|
networkMock.onGet('/types/').reply(200, {data: [{label: 'text'}]});
|
|
networkMock.onGet('/broken/').reply(500, {errormsg: 'nope'});
|
|
});
|
|
|
|
afterEach(()=>{
|
|
networkMock.restore();
|
|
});
|
|
|
|
it('shares one request among concurrent callers', async ()=>{
|
|
// Every getNodeAjaxOptions() call builds its own api instance, so the
|
|
// shared request has to survive distinct-but-identically-configured ones.
|
|
const [res1, res2] = await Promise.all([
|
|
getInflight(getApiInstance(), '/types/', {params: {tid: 1}}),
|
|
getInflight(getApiInstance(), '/types/', {params: {tid: 1}}),
|
|
]);
|
|
|
|
expect(networkMock.history.get.length).toBe(1);
|
|
expect(res1.data).toEqual({data: [{label: 'text'}]});
|
|
expect(res2).toBe(res1);
|
|
});
|
|
|
|
it('ignores param order when matching', async ()=>{
|
|
await Promise.all([
|
|
getInflight(getApiInstance(), '/types/', {params: {tid: 1, scid: 2}}),
|
|
getInflight(getApiInstance(), '/types/', {params: {scid: 2, tid: 1}}),
|
|
]);
|
|
|
|
expect(networkMock.history.get.length).toBe(1);
|
|
});
|
|
|
|
it('does not share requests with different params', async ()=>{
|
|
await Promise.all([
|
|
getInflight(getApiInstance(), '/types/', {params: {tid: 1}}),
|
|
getInflight(getApiInstance(), '/types/', {params: {tid: 2}}),
|
|
]);
|
|
|
|
expect(networkMock.history.get.length).toBe(2);
|
|
});
|
|
|
|
it('does not share requests with different URLSearchParams', async ()=>{
|
|
await Promise.all([
|
|
getInflight(getApiInstance(), '/types/', {
|
|
params: new URLSearchParams([['tid', '1']]),
|
|
}),
|
|
getInflight(getApiInstance(), '/types/', {
|
|
params: new URLSearchParams([['tid', '2']]),
|
|
}),
|
|
]);
|
|
|
|
expect(networkMock.history.get.length).toBe(2);
|
|
expect(
|
|
networkMock.history.get.map((r)=>r.params.toString()).sort()
|
|
).toEqual(['tid=1', 'tid=2']);
|
|
});
|
|
|
|
it('does not share requests serialised differently', async ()=>{
|
|
await Promise.all([
|
|
getInflight(getApiInstance(), '/types/', {
|
|
params: {tid: 1},
|
|
paramsSerializer: {serialize: ()=>'tid=1'},
|
|
}),
|
|
getInflight(getApiInstance(), '/types/', {
|
|
params: {tid: 1},
|
|
paramsSerializer: {serialize: ()=>'tid=2'},
|
|
}),
|
|
]);
|
|
|
|
expect(networkMock.history.get.length).toBe(2);
|
|
});
|
|
|
|
it('does not share requests with different instance headers', async ()=>{
|
|
await Promise.all([
|
|
getInflight(getApiInstance(), '/types/', {params: {tid: 1}}),
|
|
getInflight(
|
|
getApiInstance({'Content-Encoding': 'gzip'}), '/types/',
|
|
{params: {tid: 1}}
|
|
),
|
|
]);
|
|
|
|
expect(networkMock.history.get.length).toBe(2);
|
|
});
|
|
|
|
it('does not share requests with different per-request headers', async ()=>{
|
|
await Promise.all([
|
|
getInflight(getApiInstance(), '/types/', {
|
|
params: {tid: 1}, headers: {'X-Test': 'a'},
|
|
}),
|
|
getInflight(getApiInstance(), '/types/', {
|
|
params: {tid: 1}, headers: {'X-Test': 'b'},
|
|
}),
|
|
]);
|
|
|
|
expect(networkMock.history.get.length).toBe(2);
|
|
});
|
|
|
|
it('never shares requests carrying unkeyed config', async ()=>{
|
|
// responseType changes what the caller gets back, so these two must not
|
|
// be handed the same response even though url and params match.
|
|
await Promise.all([
|
|
getInflight(getApiInstance(), '/types/', {
|
|
params: {tid: 1}, responseType: 'text',
|
|
}),
|
|
getInflight(getApiInstance(), '/types/', {
|
|
params: {tid: 1}, responseType: 'text',
|
|
}),
|
|
]);
|
|
|
|
expect(networkMock.history.get.length).toBe(2);
|
|
});
|
|
|
|
it('fetches again once the shared request has settled', async ()=>{
|
|
await getInflight(getApiInstance(), '/types/', {params: {tid: 1}});
|
|
await getInflight(getApiInstance(), '/types/', {params: {tid: 1}});
|
|
|
|
expect(networkMock.history.get.length).toBe(2);
|
|
});
|
|
|
|
it('rejects every caller and retries after a failure', async ()=>{
|
|
const attempts = [
|
|
getInflight(getApiInstance(), '/broken/'),
|
|
getInflight(getApiInstance(), '/broken/'),
|
|
];
|
|
|
|
await expect(attempts[0]).rejects.toThrow();
|
|
await expect(attempts[1]).rejects.toThrow();
|
|
expect(networkMock.history.get.length).toBe(1);
|
|
|
|
// The failed entry must not stick around and poison later callers.
|
|
await expect(
|
|
getInflight(getApiInstance(), '/broken/')
|
|
).rejects.toThrow();
|
|
expect(networkMock.history.get.length).toBe(2);
|
|
});
|
|
});
|