Skip to content

Commit 42fa8c2

Browse files
authored
perf: parse records in linear time instead of quadratic (#649)
1 parent ef0cefa commit 42fa8c2

2 files changed

Lines changed: 111 additions & 14 deletions

File tree

‎packages/sitemapd/src/parse/index.ts‎

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,18 @@ function extractFeedMetadata(
275275
return undefined
276276
}
277277

278+
/**
279+
* Case-insensitive `startsWith` that only lowercases the prefix-length slice.
280+
*
281+
* `source.toLowerCase().startsWith(prefix)` copies the WHOLE remaining document
282+
* to compare a handful of characters, and the record loop calls it once per
283+
* entry, so it is O(entries x document length) on its own.
284+
*/
285+
function startsWithCI(input: string, prefix: string): boolean {
286+
return input.length >= prefix.length
287+
&& input.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase()
288+
}
289+
278290
function extractRecord(
279291
input: string,
280292
root: RootState,
@@ -284,12 +296,12 @@ function extractRecord(
284296
if (source.startsWith('<!--') || source.startsWith('<?'))
285297
return undefined
286298
const rootClose = `</${root.qualifiedName}>`
287-
if (source.toLowerCase().startsWith(rootClose.toLowerCase()))
299+
if (startsWithCI(source, rootClose))
288300
return { _tag: 'close', rest: source.slice(rootClose.length) }
289-
if (root.name === 'rss' && source.toLowerCase().startsWith('</channel>')) {
301+
if (root.name === 'rss' && startsWithCI(source, '</channel>')) {
290302
const rest = trimMarkupPrefix(source.slice('</channel>'.length))
291303
const rssClose = `</${root.qualifiedName}>`
292-
if (rest.toLowerCase().startsWith(rssClose.toLowerCase()))
304+
if (startsWithCI(rest, rssClose))
293305
return { _tag: 'close', rest: rest.slice(rssClose.length) }
294306
if (rest.length > 0 && findMarkupEnd(rest, 0) !== -1)
295307
return { _tag: 'malformed', detail: 'RSS channel is not followed by its closing root' }
@@ -335,23 +347,48 @@ function extractRecord(
335347
}
336348
}
337349

350+
function escapeRegExp(value: string): string {
351+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
352+
}
353+
354+
/**
355+
* Locate a record's closing tag, skipping any comment or CDATA section that
356+
* contains a lookalike.
357+
*
358+
* Two costs here used to scale with the whole remaining document on EVERY
359+
* record, which made parsing quadratic in document size (a 40,000-entry, 8.8MB
360+
* sitemap took ~229s):
361+
*
362+
* 1. `input.toLowerCase()` copied the entire remaining buffer per call, purely
363+
* to do a case-insensitive search. A sticky case-insensitive RegExp scans in
364+
* place instead.
365+
* 2. `indexOf('<!--', cursor)` and `indexOf('<![CDATA[', cursor)` were
366+
* UNBOUNDED. Ordinary sitemaps contain neither, so both ran to the end of
367+
* the buffer just to report "absent" — once per record. They are now bounded
368+
* to `[cursor, closeIndex)`, the only window where a hidden section could
369+
* actually affect which close tag is real.
370+
*
371+
* Behaviour is unchanged; only the complexity is. Same file, 8.8MB: ~0.55s.
372+
*/
338373
function findRecordClose(input: string, close: string, start: number): number {
339-
const lower = input.toLowerCase()
340-
const lowerClose = close.toLowerCase()
374+
const closePattern = new RegExp(escapeRegExp(close), 'gi')
341375
let cursor = start
342376
while (true) {
343-
const closeIndex = lower.indexOf(lowerClose, cursor)
344-
if (closeIndex === -1)
377+
closePattern.lastIndex = cursor
378+
const match = closePattern.exec(input)
379+
if (!match)
345380
return -1
346-
const commentIndex = input.indexOf('<!--', cursor)
347-
const cdataIndex = input.indexOf('<![CDATA[', cursor)
348-
const hiddenIndex = [commentIndex, cdataIndex]
349-
.filter(index => index !== -1 && index < closeIndex)
381+
const closeIndex = match.index
382+
const window = input.slice(cursor, closeIndex)
383+
const commentIndexRel = window.indexOf('<!--')
384+
const cdataIndexRel = window.indexOf('<![CDATA[')
385+
const hiddenIndexRel = [commentIndexRel, cdataIndexRel]
386+
.filter(index => index !== -1)
350387
.sort((left, right) => left - right)[0]
351-
if (hiddenIndex === undefined)
388+
if (hiddenIndexRel === undefined)
352389
return closeIndex
353-
const marker = hiddenIndex === commentIndex ? '-->' : ']]>'
354-
const hiddenEnd = input.indexOf(marker, hiddenIndex)
390+
const marker = hiddenIndexRel === commentIndexRel ? '-->' : ']]>'
391+
const hiddenEnd = input.indexOf(marker, cursor + hiddenIndexRel)
355392
if (hiddenEnd === -1)
356393
return -1
357394
cursor = hiddenEnd + marker.length
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { collectSitemap } from '../src/parse'
3+
4+
// Regression guard for a quadratic record scan. `findRecordClose` used to
5+
// lowercase the entire remaining buffer per call, and searched for `<!--` and
6+
// `<![CDATA[` with no upper bound. Ordinary sitemaps contain neither, so both
7+
// searches ran to the end of the document just to report "absent", once per
8+
// record. Parsing therefore scaled with entries x document length: a real
9+
// 40,000-entry, 8.8MB sitemap took about 229 seconds, which is fatal in any
10+
// CPU-bounded runtime (it was killing a Cloudflare Worker mid-request).
11+
//
12+
// The assertions below are deliberately generous. They are not a benchmark;
13+
// they only need to separate linear from quadratic, and the gap is orders of
14+
// magnitude.
15+
16+
function sitemapOf(entries: number): string {
17+
const urls = Array.from(
18+
{ length: entries },
19+
(_, index) => `<url><loc>https://example.com/products/item-${index}-with-a-realistic-length-slug</loc><lastmod>2026-08-01</lastmod></url>`,
20+
).join('\n')
21+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>`
22+
}
23+
24+
async function timeParse(entries: number): Promise<{ ms: number, parsed: number }> {
25+
const xml = sitemapOf(entries)
26+
const started = performance.now()
27+
const result = await collectSitemap(xml, {
28+
maxDecodedBytes: Number.MAX_SAFE_INTEGER,
29+
maxEntries: entries + 1,
30+
})
31+
const ms = performance.now() - started
32+
return {
33+
ms,
34+
parsed: result._tag === 'document' ? result.document.entries.length : -1,
35+
}
36+
}
37+
38+
describe('parse performance', () => {
39+
it('parses a large sitemap in linear time', async () => {
40+
const { ms, parsed } = await timeParse(8000)
41+
42+
expect(parsed).toBe(8000)
43+
// Pre-fix this took ~8.8s; post-fix it is ~0.15s.
44+
expect(ms).toBeLessThan(5000)
45+
}, 30_000)
46+
47+
it('scales sub-quadratically as the document grows', async () => {
48+
const small = await timeParse(2000)
49+
const large = await timeParse(8000)
50+
51+
expect(small.parsed).toBe(2000)
52+
expect(large.parsed).toBe(8000)
53+
54+
// 4x the entries. Quadratic growth would be ~16x; linear is ~4x. Allow a
55+
// very loose 10x so this cannot flake on a noisy machine while still
56+
// failing outright if the quadratic behaviour returns.
57+
const growth = large.ms / Math.max(small.ms, 1)
58+
expect(growth).toBeLessThan(10)
59+
}, 60_000)
60+
})

0 commit comments

Comments
 (0)