Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions web/pgadmin/browser/static/js/node_ajax.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -114,15 +114,19 @@ 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, {
// 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. Each caller still runs
// its own caching + transform on the shared response.
getInflight(api, fullUrl, {
params: otherParams.urlParams,
}).then((res)=>{
data = res.data;
let resData = res.data;
if(res.data.data) {
data = res.data.data;
resData = res.data.data;
}
otherParams.useCache && cacheNode.cache(nodeObj.type + '#' + url, treeNodeInfo, cacheLevel, data);
resolve(transform(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'));
});
Expand Down
55 changes: 55 additions & 0 deletions web/pgadmin/static/js/api_instance.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,61 @@ 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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/* 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.
* 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 typeof value + ':' + 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(',') + '}';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/* 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={}) {
// 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(() => {
_inflightGetRequests.delete(key);
});
_inflightGetRequests.set(key, request);
Comment on lines +73 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

rg -n -C3 '\b(timeout|AbortController|signal)\b' \
  web/pgadmin/static/js/api_instance.js \
  web/pgadmin/browser/static/js/node_ajax.js

Repository: pgadmin-org/pgadmin4

Length of output: 158


🏁 Script executed:

#!/bin/bash
set -u

echo "== locate relevant files =="
git ls-files | fgrep -E '(^|/)api_instance\.js$|node_ajax\.js$|axios|api' | sed -n '1,200p'

echo
echo "== api_instance.js outline/size/contents =="
wc -l web/pgadmin/static/js/api_instance.js
sed -n '1,220p' web/pgadmin/static/js/api_instance.js

echo
echo "== node_ajax.js relevant symbols/size =="
wc -l web/pgadmin/browser/static/js/node_ajax.js
sed -n '1,220p' web/pgadmin/browser/static/js/node_ajax.js

echo
echo "== all getApiInstance calls =="
rg -n -C2 'getApiInstance|_inflightGetRequests|apiInstance|axios\.get' web/pgadmin/static/js web/pgadmin -g '*.js'

Repository: pgadmin-org/pgadmin4

Length of output: 50376


🌐 Web query:

Axios defaults request timeout default 0 infinite

💡 Result:

The default value for the Axios request timeout is 0 [1][2]. In Axios, a timeout value of 0 means there is no timeout, effectively causing the request to wait indefinitely [1][3][4]. It is highly recommended to set an explicit timeout value in production environments [1]. Without a defined timeout, a stalled or hanging request can remain open indefinitely, which may lead to resource exhaustion or unexpected application behavior [1][3]. When a request exceeds the specified timeout duration, Axios aborts the request and throws an error with the code 'ECONNABORTED' [1][4]. Note that the standard timeout property primarily governs response timeouts; it may not always account for connection-level issues, for which alternative approaches like AbortSignal are recommended [5].

Citations:


Enforce a timeout before registering shared GET requests.

getInflight() stores the Axios promise before it settles, and the axios instance has no timeout/abort configuration, so a stalled request can retain the key forever and make later callers await the same pending request. Add a finite timeout (and/or cancellation handling) inside the request wrapper and clean the map on both abort and resolution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/pgadmin/static/js/api_instance.js` around lines 56 - 59, Update
getInflight() so each shared Axios GET request uses a finite timeout or
cancellation mechanism before being stored in _inflightGetRequests. Ensure the
map entry is removed when the request aborts, rejects, or resolves, preventing
stalled requests from retaining the key indefinitely.

}
return request;
}

export function parseApiError(error, withData=false) {
if (error.response) {
// The request was made and the server responded with a status code
Expand Down
Loading