From eae3f97a44ad0a93ad963b2581cea88edb2a74ac Mon Sep 17 00:00:00 2001 From: hiteshjambhale Date: Fri, 17 Jul 2026 11:17:58 +0530 Subject: [PATCH 1/3] fix: dedupe concurrent getNodeAjaxOptions requests via in-flight sharing getNodeAjaxOptions cached responses only after they resolved, so concurrent callers for the same URL (e.g. every column row's Data Type dropdown mounting at once) all saw an empty cache and each fired its own identical HTTP GET. Track in-flight requests in a module-level Map keyed by the resolved URL and query params. Concurrent callers now await the same underlying request; the first to resolve populates the cache as before, and the entry is cleaned up on settle so the map does not leak. Fixes #10155 Co-Authored-By: Claude Opus 4.8 --- web/pgadmin/browser/static/js/node_ajax.js | 38 +++++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/web/pgadmin/browser/static/js/node_ajax.js b/web/pgadmin/browser/static/js/node_ajax.js index 3d71b29f827..c5749fa0473 100644 --- a/web/pgadmin/browser/static/js/node_ajax.js +++ b/web/pgadmin/browser/static/js/node_ajax.js @@ -77,6 +77,13 @@ export function generateNodeUrl(treeNodeInfo, actionType, itemNodeData, withId, } +/* Tracks AJAX requests that are currently in flight, keyed by the resolved + * URL + query params. This lets concurrent callers asking for the same options + * (e.g. every row's Data Type dropdown mounting at once) share a single HTTP + * request instead of each firing their own before the cache is populated. + */ +const _inflightAjaxRequests = new Map(); + /* Get the nodes list as options required by select controls * The options are cached for performance reasons. */ @@ -114,15 +121,28 @@ export function getNodeAjaxOptions(url, nodeObj, treeNodeInfo, itemNodeData, par let data = cacheNode.cache(nodeObj.type + '#' + url, treeNodeInfo, cacheLevel); if (_.isUndefined(data) || _.isNull(data)) { - api.get(fullUrl, { - params: otherParams.urlParams, - }).then((res)=>{ - data = res.data; - if(res.data.data) { - data = res.data.data; - } - otherParams.useCache && cacheNode.cache(nodeObj.type + '#' + url, treeNodeInfo, cacheLevel, data); - resolve(transform(data)); + // Share a single in-flight request among all concurrent callers asking + // for the same URL + params, so we don't fire duplicate GETs before the + // first response lands and populates the cache. + let inflightKey = fullUrl + '#' + JSON.stringify(otherParams.urlParams || {}); + let request = _inflightAjaxRequests.get(inflightKey); + if (!request) { + request = api.get(fullUrl, { + params: otherParams.urlParams, + }).then((res)=>{ + let resData = res.data; + if(res.data.data) { + resData = res.data.data; + } + otherParams.useCache && cacheNode.cache(nodeObj.type + '#' + url, treeNodeInfo, cacheLevel, resData); + return resData; + }).finally(()=>{ + _inflightAjaxRequests.delete(inflightKey); + }); + _inflightAjaxRequests.set(inflightKey, request); + } + request.then((resData)=>{ + resolve(transform(resData)); }).catch((err)=>{ reject(err instanceof Error ? err : Error('Something went wrong')); }); From 10512a4a20f871d4ea44bb7a28ac4625f1ed11a4 Mon Sep 17 00:00:00 2001 From: hiteshjambhale Date: Fri, 24 Jul 2026 11:54:12 +0530 Subject: [PATCH 2/3] refactor: move in-flight GET dedup into the Axios wrapper Address review feedback on the getNodeAjaxOptions dedup: - Move the in-flight request sharing out of node_ajax.js and into api_instance.js as a reusable getInflight() helper, so any part of the app can dedupe identical concurrent GETs, not just node options. - Build the in-flight key with a stable, key-order-independent stringify (sorted keys, recursive) so {a:1,b:2} and {b:2,a:1} and nested params resolve to the same key. - Run caching and transform per-caller in each caller's own .then on the shared response, so concurrent callers with differing params each cache correctly instead of inheriting the first caller's. Co-Authored-By: Claude Opus 4.8 --- web/pgadmin/browser/static/js/node_ajax.js | 38 +++++++--------------- web/pgadmin/static/js/api_instance.js | 38 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 27 deletions(-) diff --git a/web/pgadmin/browser/static/js/node_ajax.js b/web/pgadmin/browser/static/js/node_ajax.js index c5749fa0473..8ba04f7469b 100644 --- a/web/pgadmin/browser/static/js/node_ajax.js +++ b/web/pgadmin/browser/static/js/node_ajax.js @@ -8,7 +8,7 @@ ////////////////////////////////////////////////////////////// import _ from 'lodash'; -import getApiInstance from '../../../static/js/api_instance'; +import getApiInstance, {getInflight} from '../../../static/js/api_instance'; import {generate_url} from 'sources/browser/generate_url'; import pgAdmin from 'sources/pgadmin'; @@ -77,13 +77,6 @@ export function generateNodeUrl(treeNodeInfo, actionType, itemNodeData, withId, } -/* Tracks AJAX requests that are currently in flight, keyed by the resolved - * URL + query params. This lets concurrent callers asking for the same options - * (e.g. every row's Data Type dropdown mounting at once) share a single HTTP - * request instead of each firing their own before the cache is populated. - */ -const _inflightAjaxRequests = new Map(); - /* Get the nodes list as options required by select controls * The options are cached for performance reasons. */ @@ -123,25 +116,16 @@ export function getNodeAjaxOptions(url, nodeObj, treeNodeInfo, itemNodeData, par if (_.isUndefined(data) || _.isNull(data)) { // Share a single in-flight request among all concurrent callers asking // for the same URL + params, so we don't fire duplicate GETs before the - // first response lands and populates the cache. - let inflightKey = fullUrl + '#' + JSON.stringify(otherParams.urlParams || {}); - let request = _inflightAjaxRequests.get(inflightKey); - if (!request) { - request = api.get(fullUrl, { - params: otherParams.urlParams, - }).then((res)=>{ - let resData = res.data; - if(res.data.data) { - resData = res.data.data; - } - otherParams.useCache && cacheNode.cache(nodeObj.type + '#' + url, treeNodeInfo, cacheLevel, resData); - return resData; - }).finally(()=>{ - _inflightAjaxRequests.delete(inflightKey); - }); - _inflightAjaxRequests.set(inflightKey, request); - } - request.then((resData)=>{ + // first response lands and populates the cache. Each caller still runs + // its own caching + transform on the shared response. + getInflight(api, fullUrl, { + params: otherParams.urlParams, + }).then((res)=>{ + let resData = res.data; + if(res.data.data) { + resData = res.data.data; + } + otherParams.useCache && cacheNode.cache(nodeObj.type + '#' + url, treeNodeInfo, cacheLevel, resData); resolve(transform(resData)); }).catch((err)=>{ reject(err instanceof Error ? err : Error('Something went wrong')); diff --git a/web/pgadmin/static/js/api_instance.js b/web/pgadmin/static/js/api_instance.js index fda59b2190e..13c1daadae4 100644 --- a/web/pgadmin/static/js/api_instance.js +++ b/web/pgadmin/static/js/api_instance.js @@ -23,6 +23,44 @@ export default function getApiInstance(headers={}) { }); } +/* Tracks GET requests that are currently in flight, keyed by the resolved URL + + * params. This lets concurrent callers asking for the exact same resource + * (e.g. every column row's Data Type dropdown mounting at once on a wide table) + * share a single HTTP request instead of each firing their own identical GET. + */ +const _inflightGetRequests = new Map(); + +/* Builds a key that is independent of object key insertion order, so that + * {a:1, b:2} and {b:2, a:1} (and nested variants) resolve to the same key. */ +function stableStringify(value) { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return '[' + value.map(stableStringify).join(',') + ']'; + } + return '{' + Object.keys(value).sort().map( + (key) => JSON.stringify(key) + ':' + stableStringify(value[key]) + ).join(',') + '}'; +} + +/* Like api.get(url, config), but shares a single in-flight request among all + * concurrent callers requesting the same url + params. The shared entry is + * removed once the request settles (success or failure), so it never leaks and + * later calls fetch fresh data. Each caller attaches its own then/catch, so + * response handling (transform, caching, etc.) stays per-caller. */ +export function getInflight(api, url, config={}) { + const key = url + '#' + stableStringify(config.params ?? {}); + let request = _inflightGetRequests.get(key); + if (!request) { + request = api.get(url, config).finally(() => { + _inflightGetRequests.delete(key); + }); + _inflightGetRequests.set(key, request); + } + return request; +} + export function parseApiError(error, withData=false) { if (error.response) { // The request was made and the server responded with a status code From 73bfc892aa2e6cf84f35e4aef81bc0c514b62224 Mon Sep 17 00:00:00 2001 From: hiteshjambhale Date: Fri, 24 Jul 2026 16:51:39 +0530 Subject: [PATCH 3/3] harden getInflight request key against header and serialization collisions Address CodeRabbit review on #10159. getInflight is exported, so key building should be safe regardless of caller, even though the sole caller today (getNodeAjaxOptions) can't trigger these cases: - Include config.headers in the key so callers differing only in headers (e.g. a different Authorization) never share an in-flight response. - Type-tag primitives in stableStringify so values with the same JSON form but different types no longer collide (number 1 vs string "1"), and handle undefined so [undefined] no longer collapses to []. Insertion-order independence and the undefined-params default are unchanged. --- web/pgadmin/static/js/api_instance.js | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/web/pgadmin/static/js/api_instance.js b/web/pgadmin/static/js/api_instance.js index 13c1daadae4..24d051d64a9 100644 --- a/web/pgadmin/static/js/api_instance.js +++ b/web/pgadmin/static/js/api_instance.js @@ -31,10 +31,19 @@ export default function getApiInstance(headers={}) { const _inflightGetRequests = new Map(); /* Builds a key that is independent of object key insertion order, so that - * {a:1, b:2} and {b:2, a:1} (and nested variants) resolve to the same key. */ + * {a:1, b:2} and {b:2, a:1} (and nested variants) resolve to the same key. + * Params are expected to be plain JSON-like query values (primitives, plain + * objects, arrays); exotic inputs like Date/Map/cyclic objects are not valid + * GET query params and are out of scope here. + * Primitives are type-tagged so that values that share a JSON form but differ + * in type never collide, e.g. the number 1 vs the string "1", or an array hole + * ([undefined]) vs the empty array ([]). */ function stableStringify(value) { + if (value === undefined) { + return 'undefined'; + } if (value === null || typeof value !== 'object') { - return JSON.stringify(value); + return typeof value + ':' + JSON.stringify(value); } if (Array.isArray(value)) { return '[' + value.map(stableStringify).join(',') + ']'; @@ -50,7 +59,15 @@ function stableStringify(value) { * later calls fetch fresh data. Each caller attaches its own then/catch, so * response handling (transform, caching, etc.) stays per-caller. */ export function getInflight(api, url, config={}) { - const key = url + '#' + stableStringify(config.params ?? {}); + // Key on url + params + any per-request headers, so callers that differ only + // in headers (e.g. a different Authorization) never share a response. The + // instance-level headers set by getApiInstance() are the session-global CSRF + // token and Content-type, identical across concurrent callers, so they don't + // need to be in the key; anything caller-specific should be passed here via + // config.headers. + const key = url + + '#' + stableStringify(config.params ?? {}) + + '#' + stableStringify(config.headers ?? {}); let request = _inflightGetRequests.get(key); if (!request) { request = api.get(url, config).finally(() => {