Skip to content

Commit f62738b

Browse files
Merge pull request #4 from mapbox/fix-batch-concurrency
Fix transient memory amplification in batch_get_documents_tool
2 parents 1cf0ee4 + ec685e3 commit f62738b

5 files changed

Lines changed: 238 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
## Unreleased
22

3+
### Fix transient memory amplification in `batch_get_documents_tool` (#4)
4+
5+
- Deduplicate intra-batch URLs by normalized key: multiple input URLs that resolve to the same page (e.g. cache-busting query params) now share a single HTTP request and a single buffered body instead of each triggering a separate fetch
6+
- Add streaming response body size cap via `readBodyWithLimit`: aborts reads that exceed 2 MB before the full body is buffered; also rejects on `Content-Length` before reading begins
7+
- Apply the same body size cap to `get_document_tool`
8+
39
### Fix unbounded cache growth in `DocCache` (#2)
410

511
- Added a 512-entry LRU eviction limit to prevent unbounded Map growth

src/tools/batch-get-documents-tool/BatchGetDocumentsTool.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
// Licensed under the MIT License.
33

44
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
5-
import { docCache } from '../../utils/docCache.js';
5+
import {
6+
docCache,
7+
normalizeCacheKey,
8+
MAX_ENTRY_BYTES,
9+
readBodyWithLimit
10+
} from '../../utils/docCache.js';
611
import type { HttpRequest } from '../../utils/types.js';
712
import { BaseTool } from '../BaseTool.js';
813
import {
@@ -60,13 +65,20 @@ export class BatchGetDocumentsTool extends BaseTool<
6065
};
6166
}
6267

63-
const results = await Promise.allSettled(
64-
input.urls.map(async (url): Promise<DocumentResult> => {
65-
const cached = docCache.get(url);
66-
if (cached !== null) {
67-
return { url, content: cached };
68-
}
68+
// One in-flight fetch per unique normalized URL. Multiple input URLs that
69+
// normalize to the same key (e.g. cache-busting query params) share a
70+
// single HTTP request and a single buffered body.
71+
const inflightByKey = new Map<string, Promise<string>>();
72+
73+
const fetchOne = (url: string): Promise<string> => {
74+
const cached = docCache.get(url);
75+
if (cached !== null) return Promise.resolve(cached);
76+
77+
const key = normalizeCacheKey(url);
78+
const existing = inflightByKey.get(key);
79+
if (existing) return existing;
6980

81+
const promise = (async () => {
7082
const response = await this.httpRequest(url, {
7183
headers: { Accept: 'text/markdown, text/plain;q=0.9, */*;q=0.8' }
7284
});
@@ -75,8 +87,18 @@ export class BatchGetDocumentsTool extends BaseTool<
7587
throw new Error(`${response.status} ${response.statusText}`);
7688
}
7789

78-
const content = await response.text();
90+
const content = await readBodyWithLimit(response, MAX_ENTRY_BYTES);
7991
docCache.set(url, content);
92+
return content;
93+
})();
94+
95+
inflightByKey.set(key, promise);
96+
return promise;
97+
};
98+
99+
const results = await Promise.allSettled(
100+
input.urls.map(async (url): Promise<DocumentResult> => {
101+
const content = await fetchOne(url);
80102
return { url, content };
81103
})
82104
);

src/tools/get-document-tool/GetDocumentTool.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
// Licensed under the MIT License.
33

44
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
5-
import { docCache } from '../../utils/docCache.js';
5+
import {
6+
docCache,
7+
MAX_ENTRY_BYTES,
8+
readBodyWithLimit
9+
} from '../../utils/docCache.js';
610
import type { HttpRequest } from '../../utils/types.js';
711
import { BaseTool } from '../BaseTool.js';
812
import {
@@ -73,7 +77,7 @@ export class GetDocumentTool extends BaseTool<typeof GetDocumentSchema> {
7377
};
7478
}
7579

76-
const content = await response.text();
80+
const content = await readBodyWithLimit(response, MAX_ENTRY_BYTES);
7781
docCache.set(input.url, content);
7882

7983
return { content: [{ type: 'text', text: content }], isError: false };

src/utils/docCache.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const DEFAULT_TTL_MS = parseInt(
88

99
// Cache limits
1010
const MAX_ENTRIES = 512;
11-
const MAX_ENTRY_BYTES = 2 * 1024 * 1024; // 2 MB per entry
11+
export const MAX_ENTRY_BYTES = 2 * 1024 * 1024; // 2 MB per entry
1212
const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB total
1313

1414
interface CacheEntry {
@@ -104,3 +104,51 @@ class DocCache {
104104
}
105105

106106
export const docCache = new DocCache();
107+
108+
/**
109+
* Read a Response body up to `maxBytes`, aborting early if the limit is
110+
* exceeded. Checks Content-Length first when present so no bytes are
111+
* buffered for obviously-oversized responses.
112+
*/
113+
export async function readBodyWithLimit(
114+
response: Response,
115+
maxBytes: number
116+
): Promise<string> {
117+
const contentLength = response.headers.get('content-length');
118+
if (contentLength) {
119+
const cl = parseInt(contentLength, 10);
120+
if (Number.isFinite(cl) && cl > maxBytes) {
121+
throw new Error(
122+
`Response too large: Content-Length ${cl} exceeds limit of ${maxBytes} bytes`
123+
);
124+
}
125+
}
126+
127+
if (!response.body) {
128+
const text = await response.text();
129+
if (Buffer.byteLength(text, 'utf8') > maxBytes) {
130+
throw new Error('Response too large');
131+
}
132+
return text;
133+
}
134+
135+
const chunks: Buffer[] = [];
136+
let totalBytes = 0;
137+
const reader = response.body.getReader();
138+
139+
try {
140+
for (;;) {
141+
const { done, value } = await reader.read();
142+
if (done) break;
143+
totalBytes += value.byteLength;
144+
if (totalBytes > maxBytes) {
145+
throw new Error('Response too large');
146+
}
147+
chunks.push(Buffer.from(value));
148+
}
149+
} finally {
150+
reader.releaseLock();
151+
}
152+
153+
return Buffer.concat(chunks).toString('utf8');
154+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright (c) Mapbox, Inc.
2+
// Licensed under the MIT License.
3+
4+
import { describe, it, expect, vi, beforeEach } from 'vitest';
5+
import { BatchGetDocumentsTool } from '../../../src/tools/batch-get-documents-tool/BatchGetDocumentsTool.js';
6+
import { docCache } from '../../../src/utils/docCache.js';
7+
8+
beforeEach(() => {
9+
docCache.clear();
10+
});
11+
12+
function makeResponse(body: string, status = 200): Response {
13+
return new Response(body, {
14+
status,
15+
headers: {
16+
'content-type': 'text/plain',
17+
'content-length': String(Buffer.byteLength(body, 'utf8'))
18+
}
19+
});
20+
}
21+
22+
describe('BatchGetDocumentsTool', () => {
23+
describe('intra-batch URL deduplication', () => {
24+
it('issues only one HTTP request for multiple URLs with the same normalized key', async () => {
25+
const httpRequest = vi
26+
.fn()
27+
.mockResolvedValue(makeResponse('page content'));
28+
const tool = new BatchGetDocumentsTool({ httpRequest });
29+
30+
const urls = [
31+
'https://docs.mapbox.com/page?bust=1',
32+
'https://docs.mapbox.com/page?bust=2',
33+
'https://docs.mapbox.com/page?bust=3'
34+
];
35+
36+
const result = await tool.run({ urls });
37+
38+
expect(httpRequest).toHaveBeenCalledTimes(1);
39+
expect(result.isError).toBe(false);
40+
41+
const output = JSON.parse((result.content[0] as { text: string }).text);
42+
expect(output).toHaveLength(3);
43+
expect(
44+
output.every((r: { content: string }) => r.content === 'page content')
45+
).toBe(true);
46+
});
47+
48+
it('issues one request per distinct normalized URL', async () => {
49+
const httpRequest = vi
50+
.fn()
51+
.mockResolvedValueOnce(makeResponse('page A'))
52+
.mockResolvedValueOnce(makeResponse('page B'));
53+
const tool = new BatchGetDocumentsTool({ httpRequest });
54+
55+
const urls = [
56+
'https://docs.mapbox.com/a?x=1',
57+
'https://docs.mapbox.com/a?x=2',
58+
'https://docs.mapbox.com/b?x=1'
59+
];
60+
61+
const result = await tool.run({ urls });
62+
63+
expect(httpRequest).toHaveBeenCalledTimes(2);
64+
expect(result.isError).toBe(false);
65+
66+
const output = JSON.parse((result.content[0] as { text: string }).text);
67+
expect(output[0].content).toBe('page A');
68+
expect(output[1].content).toBe('page A');
69+
expect(output[2].content).toBe('page B');
70+
});
71+
72+
it('uses cached content and skips fetch for already-cached normalized URL', async () => {
73+
docCache.set('https://docs.mapbox.com/page', 'cached content');
74+
const httpRequest = vi.fn();
75+
const tool = new BatchGetDocumentsTool({ httpRequest });
76+
77+
const result = await tool.run({
78+
urls: [
79+
'https://docs.mapbox.com/page?bust=1',
80+
'https://docs.mapbox.com/page?bust=2'
81+
]
82+
});
83+
84+
expect(httpRequest).not.toHaveBeenCalled();
85+
const output = JSON.parse((result.content[0] as { text: string }).text);
86+
expect(
87+
output.every((r: { content: string }) => r.content === 'cached content')
88+
).toBe(true);
89+
});
90+
});
91+
92+
describe('response body size limit', () => {
93+
it('returns an error for a URL whose Content-Length exceeds the limit', async () => {
94+
const oversizeHeaders = new Headers({
95+
'content-type': 'text/plain',
96+
'content-length': String(3 * 1024 * 1024) // 3 MB > 2 MB limit
97+
});
98+
const httpRequest = vi
99+
.fn()
100+
.mockResolvedValue(
101+
new Response('x', { status: 200, headers: oversizeHeaders })
102+
);
103+
const tool = new BatchGetDocumentsTool({ httpRequest });
104+
105+
const result = await tool.run({
106+
urls: ['https://docs.mapbox.com/page']
107+
});
108+
109+
expect(result.isError).toBe(false); // batch doesn't fail entirely
110+
const output = JSON.parse((result.content[0] as { text: string }).text);
111+
expect(output[0].error).toMatch(/too large/i);
112+
});
113+
});
114+
115+
describe('invalid URLs', () => {
116+
it('rejects non-mapbox URLs', async () => {
117+
const httpRequest = vi.fn();
118+
const tool = new BatchGetDocumentsTool({ httpRequest });
119+
120+
const result = await tool.run({
121+
urls: ['https://evil.com/page']
122+
});
123+
124+
expect(result.isError).toBe(true);
125+
expect(httpRequest).not.toHaveBeenCalled();
126+
});
127+
});
128+
129+
describe('HTTP errors', () => {
130+
it('returns per-URL error on non-ok response', async () => {
131+
const httpRequest = vi
132+
.fn()
133+
.mockResolvedValue(
134+
new Response('Not Found', { status: 404, statusText: 'Not Found' })
135+
);
136+
const tool = new BatchGetDocumentsTool({ httpRequest });
137+
138+
const result = await tool.run({
139+
urls: ['https://docs.mapbox.com/missing']
140+
});
141+
142+
expect(result.isError).toBe(false);
143+
const output = JSON.parse((result.content[0] as { text: string }).text);
144+
expect(output[0].error).toBe('404 Not Found');
145+
});
146+
});
147+
});

0 commit comments

Comments
 (0)