@cyanheads/pubmed-mcp-server
Version:
Search PubMed/Europe PMC, fetch articles and full text (PMC/EPMC/Unpaywall), citations, MeSH terms via MCP. STDIO or Streamable HTTP.
107 lines • 4.19 kB
JavaScript
/**
* @fileoverview Rate-limited request scheduler for Europe PMC calls. Caps
* concurrent in-flight requests and enforces a minimum start-gap between
* dispatches to stay polite with EBI's infrastructure. Independent rate
* domain from NCBI's queue — Europe PMC runs on a different host with its
* own limits.
* @module src/services/europe-pmc/request-queue
*/
import { logger, requestContextService } from '@cyanheads/mcp-ts-core/utils';
const DEFAULT_MAX_CONCURRENT = 4;
/**
* Schedules Europe PMC requests against two independent ceilings:
*
* - **Throughput** (`minStartGapMs`): minimum delay between two consecutive
* dispatch times. Defaults to 200ms to be polite with EBI.
* - **Concurrency** (`maxConcurrent`): maximum simultaneous in-flight
* requests. Decouples concurrency from rate so slow upstream responses
* don't block new dispatches.
*
* Enqueue accepts an optional `AbortSignal` so callers can bound their total
* time inside the scheduler — when the signal fires, a still-waiting task
* rejects immediately instead of sitting behind a saturated worker.
*/
export class EuropePmcRequestQueue {
waiters = [];
minStartGapMs;
maxConcurrent;
inFlight = 0;
lastStartTime = 0;
nextDispatchTimer;
constructor(minStartGapMs, maxConcurrent = DEFAULT_MAX_CONCURRENT) {
this.minStartGapMs = minStartGapMs;
this.maxConcurrent = maxConcurrent;
}
enqueue(task, label, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const waiter = {
resolve,
reject,
task,
label,
...(signal && { signal }),
};
if (signal) {
const onAbort = () => {
const idx = this.waiters.indexOf(waiter);
if (idx === -1)
return;
this.waiters.splice(idx, 1);
reject(signal.reason);
};
waiter.onAbort = onAbort;
signal.addEventListener('abort', onAbort, { once: true });
}
this.waiters.push(waiter);
this.tryDispatch();
});
}
tryDispatch() {
if (this.nextDispatchTimer !== undefined)
return;
while (this.inFlight < this.maxConcurrent && this.waiters.length > 0) {
const now = Date.now();
const gap = this.minStartGapMs - (now - this.lastStartTime);
if (gap > 0) {
this.nextDispatchTimer = setTimeout(() => {
this.nextDispatchTimer = undefined;
this.tryDispatch();
}, gap);
return;
}
const waiter = this.waiters.shift();
if (!waiter)
return;
if (waiter.signal && waiter.onAbort) {
waiter.signal.removeEventListener('abort', waiter.onAbort);
}
this.lastStartTime = Date.now();
this.inFlight += 1;
logger.debug(`Executing Europe PMC request via queue: ${waiter.label}`, requestContextService.createRequestContext({
operation: 'EuropePmcQueueDispatch',
label: waiter.label,
inFlight: this.inFlight,
queueDepth: this.waiters.length,
}));
Promise.resolve()
.then(() => waiter.task())
.then(waiter.resolve, (err) => {
logger.error('Error processing Europe PMC request from queue.', requestContextService.createRequestContext({
operation: 'EuropePmcQueueProcess',
label: waiter.label,
errorMessage: err instanceof Error ? err.message : String(err),
}));
waiter.reject(err);
})
.finally(() => {
this.inFlight -= 1;
this.tryDispatch();
});
}
}
}
//# sourceMappingURL=request-queue.js.map