Skip to content

Commit 7a75949

Browse files
authored
feat: batch package info fetching (#67)
* refactor: use `getPackageInfo` directly in document-link * feat: add batch runner * chore: improve log * feat: support `maxSize`
1 parent 29168b6 commit 7a75949

4 files changed

Lines changed: 275 additions & 57 deletions

File tree

src/providers/document-link/npmx.ts

Lines changed: 2 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,19 @@
11
import type { Extractor } from '#types/extractor'
22
import type { DocumentLink, DocumentLinkProvider, TextDocument } from 'vscode'
3-
import { config, logger } from '#state'
3+
import { config } from '#state'
44
import { getPackageInfo } from '#utils/api/package'
55
import { npmxPackageUrl } from '#utils/links'
66
import { resolveExactVersion } from '#utils/package'
77
import { isSupportedProtocol, parseVersion } from '#utils/version'
88
import { Uri, DocumentLink as VscodeDocumentLink } from 'vscode'
99

10-
// Limit concurrent lookups to avoid overwhelming the registry and hitting rate limits
11-
const RESOLVED_LOOKUP_CONCURRENCY = 6
12-
13-
type PackageInfoResult = Awaited<ReturnType<typeof getPackageInfo>>
14-
1510
export class NpmxDocumentLinkProvider<T extends Extractor> implements DocumentLinkProvider {
1611
extractor: T
1712

1813
constructor(extractor: T) {
1914
this.extractor = extractor
2015
}
2116

22-
private async fetchResolvedPackageInfoMap(names: string[]): Promise<Map<string, PackageInfoResult>> {
23-
const packageInfoMap = new Map<string, PackageInfoResult>()
24-
25-
for (let i = 0; i < names.length; i += RESOLVED_LOOKUP_CONCURRENCY) {
26-
const batch = names.slice(i, i + RESOLVED_LOOKUP_CONCURRENCY)
27-
const results = await Promise.allSettled(batch.map(async (name) => [name, await getPackageInfo(name)] as const))
28-
29-
for (const result of results) {
30-
if (result.status === 'fulfilled') {
31-
const [name, pkg] = result.value
32-
packageInfoMap.set(name, pkg)
33-
}
34-
}
35-
36-
for (const [index, result] of results.entries()) {
37-
if (result.status === 'rejected') {
38-
const name = batch[index]
39-
logger.warn(`[package-link] failed to fetch package info for ${name}: ${String(result.reason)}`)
40-
}
41-
}
42-
}
43-
44-
return packageInfoMap
45-
}
46-
4717
async provideDocumentLinks(document: TextDocument): Promise<DocumentLink[]> {
4818
const root = this.extractor.parse(document)
4919
if (!root)
@@ -67,13 +37,6 @@ export class NpmxDocumentLinkProvider<T extends Extractor> implements DocumentLi
6737
parsedDeps.push({ dep, parsed })
6838
}
6939

70-
let packageInfoMap = new Map<string, PackageInfoResult>()
71-
72-
if (linkMode === 'resolved') {
73-
const names = [...new Set(parsedDeps.map(({ dep }) => dep.name))]
74-
packageInfoMap = await this.fetchResolvedPackageInfoMap(names)
75-
}
76-
7740
for (const { dep, parsed } of parsedDeps) {
7841
const { name, nameNode } = dep
7942

@@ -82,7 +45,7 @@ export class NpmxDocumentLinkProvider<T extends Extractor> implements DocumentLi
8245
if (linkMode === 'declared') {
8346
targetVersion = parsed.version
8447
} else if (linkMode === 'resolved') {
85-
const pkg = packageInfoMap.get(name)
48+
const pkg = await getPackageInfo(name)
8649
const exactVersion = pkg ? resolveExactVersion(pkg, parsed.version) : null
8750
targetVersion = exactVersion ?? parsed.version
8851
}

src/utils/api/package.ts

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,18 @@
1-
import type { PackageVersionsInfoWithMetadata } from 'fast-npm-meta'
1+
import type { MaybeError, PackageVersionsInfoWithMetadata } from 'fast-npm-meta'
22
import { logger } from '#state'
3-
import { getVersions } from 'fast-npm-meta'
3+
import { createBatchRunner } from '#utils/batch'
4+
import { getVersionsBatch } from 'fast-npm-meta'
45
import { memoize } from '../memoize'
56

7+
const BATCH_SIZE = 20
8+
69
export interface PackageInfo extends PackageVersionsInfoWithMetadata {
710
versionToTag: Map<string, string>
811
}
912

10-
/**
11-
* Fetch npm package versions and build a version-to-tag lookup map.
12-
*
13-
* @see https://github.com/antfu/fast-npm-meta
14-
*/
15-
export const getPackageInfo = memoize<string, Promise<PackageInfo | null>>(async (name) => {
16-
logger.info(`Fetching package info for ${name}`)
17-
18-
const pkg = await getVersions(name, {
19-
metadata: true,
20-
throw: false,
21-
})
22-
13+
function parsePackageInfo(name: string, pkg: MaybeError<PackageVersionsInfoWithMetadata>) {
2314
if ('error' in pkg) {
24-
logger.warn(`Fetching package info for ${name} error: ${JSON.stringify(pkg)}`)
15+
logger.warn(`[package] Fetching error(${name}): ${JSON.stringify(pkg)}`)
2516

2617
// Return null to trigger a cache hit
2718
if (pkg.status === 404)
@@ -30,8 +21,6 @@ export const getPackageInfo = memoize<string, Promise<PackageInfo | null>>(async
3021
throw pkg
3122
}
3223

33-
logger.info(`Fetched package info for ${name}`)
34-
3524
const versionToTag = new Map<string, string>()
3625
if (pkg.distTags) {
3726
for (const [tag, ver] of Object.entries(pkg.distTags)) {
@@ -40,4 +29,45 @@ export const getPackageInfo = memoize<string, Promise<PackageInfo | null>>(async
4029
}
4130

4231
return { ...pkg, versionToTag }
32+
}
33+
34+
const getPackageInfoBatch = createBatchRunner<string, PackageInfo | null>({
35+
maxSize: BATCH_SIZE,
36+
runBatch: async (names) => {
37+
const logName = names.join(', ')
38+
logger.info(`[package] Fetching ${logName}`)
39+
40+
const list = await getVersionsBatch(names, {
41+
metadata: true,
42+
throw: false,
43+
})
44+
45+
logger.info(`[package] Fetched ${logName}`)
46+
47+
const values = new Map<string, PackageInfo | null>()
48+
const errors = new Map<string, unknown>()
49+
50+
names.forEach((name, index) => {
51+
const item = list[index]
52+
if (!item) {
53+
errors.set(name, new Error(`Missing package info response for ${name}`))
54+
return
55+
}
56+
57+
try {
58+
values.set(name, parsePackageInfo(name, item))
59+
} catch (error) {
60+
errors.set(name, error)
61+
}
62+
})
63+
64+
return { values, errors }
65+
},
4366
})
67+
68+
/**
69+
* Fetch npm package versions and build a version-to-tag lookup map.
70+
*
71+
* @see https://github.com/antfu/fast-npm-meta
72+
*/
73+
export const getPackageInfo = memoize<string, Promise<PackageInfo | null>>(async (name) => getPackageInfoBatch(name))

src/utils/batch.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
export interface BatchRunResult<TResult> {
2+
values: Map<string, TResult>
3+
errors: Map<string, unknown>
4+
}
5+
6+
export interface BatchRunnerOptions<TTask, TResult> {
7+
getKey?: (task: TTask) => string
8+
maxSize?: number
9+
runBatch: (tasks: TTask[]) => Promise<BatchRunResult<TResult>>
10+
}
11+
12+
interface PendingRequest<TResult> {
13+
resolve: (value: TResult) => void
14+
reject: (reason?: unknown) => void
15+
}
16+
17+
interface PendingTaskGroup<TTask, TResult> {
18+
task: TTask
19+
waiters: PendingRequest<TResult>[]
20+
}
21+
22+
export function createBatchRunner<TTask, TResult>(options: BatchRunnerOptions<TTask, TResult>): (task: TTask) => Promise<TResult> {
23+
const {
24+
getKey = String,
25+
maxSize,
26+
runBatch,
27+
} = options
28+
const pendingTasksByKey = new Map<string, PendingTaskGroup<TTask, TResult>>()
29+
30+
let isFlushScheduled = false
31+
32+
const resolveTaskGroup = (group: PendingTaskGroup<TTask, TResult>, value: TResult) => {
33+
group.waiters.forEach(({ resolve }) => resolve(value))
34+
}
35+
36+
const rejectTaskGroup = (group: PendingTaskGroup<TTask, TResult>, error: unknown) => {
37+
group.waiters.forEach(({ reject }) => reject(error))
38+
}
39+
40+
const flushPendingTasks = async () => {
41+
isFlushScheduled = false
42+
if (pendingTasksByKey.size === 0)
43+
return
44+
45+
const pendingTaskGroups = [...pendingTasksByKey.values()]
46+
pendingTasksByKey.clear()
47+
48+
let batchResult: BatchRunResult<TResult>
49+
50+
try {
51+
batchResult = await runBatch(pendingTaskGroups.map((group) => group.task))
52+
} catch (batchError) {
53+
pendingTaskGroups.forEach((group) => {
54+
rejectTaskGroup(group, batchError)
55+
})
56+
return
57+
}
58+
59+
pendingTaskGroups.forEach((group) => {
60+
const taskKey = getKey(group.task)
61+
if (batchResult.values.has(taskKey)) {
62+
resolveTaskGroup(group, batchResult.values.get(taskKey)!)
63+
return
64+
}
65+
66+
if (batchResult.errors.has(taskKey)) {
67+
rejectTaskGroup(group, batchResult.errors.get(taskKey))
68+
return
69+
}
70+
71+
rejectTaskGroup(group, new Error(`Missing batch outcome for key "${taskKey}"`))
72+
})
73+
}
74+
75+
return (task: TTask) => new Promise<TResult>((resolve, reject) => {
76+
const taskKey = getKey(task)
77+
const existingGroup = pendingTasksByKey.get(taskKey)
78+
79+
if (existingGroup) {
80+
existingGroup.waiters.push({ resolve, reject })
81+
} else {
82+
pendingTasksByKey.set(taskKey, {
83+
task,
84+
waiters: [{ resolve, reject }],
85+
})
86+
}
87+
88+
if (maxSize && pendingTasksByKey.size >= maxSize) {
89+
void flushPendingTasks()
90+
} else if (!isFlushScheduled) {
91+
isFlushScheduled = true
92+
queueMicrotask(() => {
93+
void flushPendingTasks()
94+
})
95+
}
96+
})
97+
}

tests/utils/batch.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { createBatchRunner } from '../../src/utils/batch'
3+
4+
describe('createBatchRunner', () => {
5+
beforeEach(() => {
6+
vi.clearAllMocks()
7+
})
8+
9+
it('should batch different tasks in the same tick', async () => {
10+
const runBatch = vi.fn(async (tasks: string[]) => {
11+
const values = new Map<string, string>()
12+
tasks.forEach((task) => {
13+
values.set(task, task.toUpperCase())
14+
})
15+
return { values, errors: new Map<string, unknown>() }
16+
})
17+
18+
const run = createBatchRunner<string, string>({
19+
runBatch,
20+
})
21+
22+
const [a, b] = await Promise.all([
23+
run('a'),
24+
run('b'),
25+
])
26+
27+
expect(a).toBe('A')
28+
expect(b).toBe('B')
29+
expect(runBatch).toHaveBeenCalledTimes(1)
30+
expect(runBatch).toHaveBeenCalledWith(['a', 'b'])
31+
})
32+
33+
it('should deduplicate same-key requests in one batch', async () => {
34+
const runBatch = vi.fn(async (tasks: string[]) => {
35+
const values = new Map<string, string>()
36+
tasks.forEach((task) => {
37+
values.set(task, `${task}-resolved`)
38+
})
39+
return { values, errors: new Map<string, unknown>() }
40+
})
41+
42+
const run = createBatchRunner<string, string>({
43+
runBatch,
44+
})
45+
46+
const [first, second] = await Promise.all([
47+
run('pkg'),
48+
run('pkg'),
49+
])
50+
51+
expect(first).toBe('pkg-resolved')
52+
expect(second).toBe('pkg-resolved')
53+
expect(runBatch).toHaveBeenCalledTimes(1)
54+
expect(runBatch).toHaveBeenCalledWith(['pkg'])
55+
})
56+
57+
it('should reject all tasks when runBatch throws', async () => {
58+
const runBatch = vi.fn(async () => {
59+
throw new Error('batch failed')
60+
})
61+
62+
const run = createBatchRunner<string, string>({
63+
runBatch,
64+
})
65+
66+
const [a, b] = await Promise.allSettled([
67+
run('a'),
68+
run('b'),
69+
])
70+
71+
expect(a.status).toBe('rejected')
72+
expect(b.status).toBe('rejected')
73+
expect(runBatch).toHaveBeenCalledTimes(1)
74+
})
75+
76+
it('should flush immediately when maxSize is reached and split into multiple batches', async () => {
77+
const runBatch = vi.fn(async (tasks: string[]) => {
78+
const values = new Map<string, string>()
79+
tasks.forEach((task) => {
80+
values.set(task, task.toUpperCase())
81+
})
82+
return { values, errors: new Map<string, unknown>() }
83+
})
84+
85+
const run = createBatchRunner<string, string>({
86+
maxSize: 2,
87+
runBatch,
88+
})
89+
90+
const a = run('a')
91+
expect(runBatch).not.toHaveBeenCalled()
92+
93+
const [, b, c] = await Promise.all([
94+
a,
95+
run('b'),
96+
run('c'),
97+
])
98+
99+
expect(await a).toBe('A')
100+
expect(b).toBe('B')
101+
expect(c).toBe('C')
102+
expect(runBatch).toHaveBeenCalledTimes(2)
103+
expect(runBatch).toHaveBeenNthCalledWith(1, ['a', 'b'])
104+
expect(runBatch).toHaveBeenNthCalledWith(2, ['c'])
105+
})
106+
107+
it('should reject errored or missing outcomes', async () => {
108+
const runBatch = vi.fn(async () => ({
109+
values: new Map<string, string>([['a', 'A']]),
110+
errors: new Map<string, unknown>([['b', new Error('b failed')]]),
111+
}))
112+
113+
const run = createBatchRunner<string, string>({
114+
runBatch,
115+
})
116+
117+
const [a, b, c] = await Promise.allSettled([
118+
run('a'),
119+
run('b'),
120+
run('c'),
121+
])
122+
123+
expect(a).toEqual({ status: 'fulfilled', value: 'A' })
124+
expect(b.status).toBe('rejected')
125+
expect(c.status).toBe('rejected')
126+
expect(runBatch).toHaveBeenCalledTimes(1)
127+
})
128+
})

0 commit comments

Comments
 (0)