Skip to content

Commit c555d20

Browse files
feat: add utilities for batching requests
1 parent 51fd80b commit c555d20

File tree

7 files changed

+479
-13
lines changed

7 files changed

+479
-13
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@primoui/utils",
33
"description": "A lightweight set of utilities",
4-
"version": "1.1.10",
4+
"version": "1.2.0",
55
"license": "MIT",
66
"type": "module",
77
"author": {

src/batch/batch.test.ts

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
import { beforeEach, describe, expect, it } from "bun:test"
2+
3+
import { processBatch, processBatchWithErrorHandling } from "./batch"
4+
5+
describe("processBatch", () => {
6+
let processedItems: number[] = []
7+
let processingOrder: number[] = []
8+
9+
beforeEach(() => {
10+
processedItems = []
11+
processingOrder = []
12+
})
13+
14+
const createProcessor =
15+
(delay = 0, shouldTrackOrder = false) =>
16+
async (item: number) => {
17+
if (shouldTrackOrder) {
18+
processingOrder.push(item)
19+
}
20+
if (delay > 0) {
21+
await new Promise(resolve => setTimeout(resolve, delay))
22+
}
23+
processedItems.push(item)
24+
return item * 2
25+
}
26+
27+
const createSlowProcessor = (delay = 50) => createProcessor(delay, true)
28+
29+
it("should process an empty array and return empty results", async () => {
30+
const items: number[] = []
31+
const processor = createProcessor()
32+
const results = await processBatch(items, processor, { batchSize: 2 })
33+
34+
expect(results).toEqual([])
35+
})
36+
37+
it("should process a single item", async () => {
38+
const items = [1]
39+
const processor = createProcessor()
40+
const results = await processBatch(items, processor, { batchSize: 2 })
41+
42+
expect(results).toEqual([2])
43+
expect(processedItems).toEqual([1])
44+
})
45+
46+
it("should process items in batches of specified size", async () => {
47+
const items = [1, 2, 3, 4, 5]
48+
const processor = createProcessor()
49+
const results = await processBatch(items, processor, { batchSize: 2 })
50+
51+
expect(results).toEqual([2, 4, 6, 8, 10])
52+
expect(processedItems).toEqual([1, 2, 3, 4, 5])
53+
})
54+
55+
it("should respect batch size when items length is not evenly divisible", async () => {
56+
const items = [1, 2, 3, 4, 5, 6, 7]
57+
const processor = createProcessor()
58+
const results = await processBatch(items, processor, { batchSize: 3 })
59+
60+
expect(results).toEqual([2, 4, 6, 8, 10, 12, 14])
61+
expect(processedItems).toEqual([1, 2, 3, 4, 5, 6, 7])
62+
})
63+
64+
it("should process with controlled concurrency", async () => {
65+
const items = [1, 2, 3, 4, 5, 6]
66+
const processor = createSlowProcessor(30)
67+
68+
const startTime = Date.now()
69+
const results = await processBatch(items, processor, {
70+
batchSize: 6,
71+
concurrency: 2,
72+
})
73+
const endTime = Date.now()
74+
75+
expect(results).toEqual([2, 4, 6, 8, 10, 12])
76+
77+
// With concurrency of 2, 6 items should take roughly 3 * 30ms = 90ms
78+
// (3 sequential pairs of concurrent operations)
79+
const duration = endTime - startTime
80+
expect(duration).toBeGreaterThan(80) // Allow some tolerance
81+
expect(duration).toBeLessThan(150)
82+
})
83+
84+
it("should process all items concurrently when concurrency >= items length", async () => {
85+
const items = [1, 2, 3, 4]
86+
const processor = createSlowProcessor(50)
87+
88+
const startTime = Date.now()
89+
const results = await processBatch(items, processor, {
90+
batchSize: 4,
91+
concurrency: 5,
92+
})
93+
const endTime = Date.now()
94+
95+
expect(results).toEqual([2, 4, 6, 8])
96+
97+
// All items should process concurrently, so should take ~50ms total
98+
const duration = endTime - startTime
99+
expect(duration).toBeGreaterThan(40)
100+
expect(duration).toBeLessThan(80)
101+
})
102+
103+
it("should add delays between batches", async () => {
104+
const items = [1, 2, 3, 4]
105+
const processor = createProcessor(5)
106+
107+
const startTime = Date.now()
108+
await processBatch(items, processor, {
109+
batchSize: 2,
110+
delay: 50,
111+
})
112+
const endTime = Date.now()
113+
114+
// Should have one delay of 50ms between the two batches
115+
const duration = endTime - startTime
116+
expect(duration).toBeGreaterThan(40) // Allow for timing variations
117+
expect(duration).toBeLessThan(200) // More generous for different system loads
118+
})
119+
120+
it("should not add delay after the last batch", async () => {
121+
const items = [1, 2]
122+
const processor = createProcessor(5)
123+
124+
const startTime = Date.now()
125+
await processBatch(items, processor, {
126+
batchSize: 2,
127+
delay: 100,
128+
})
129+
const endTime = Date.now()
130+
131+
// Should not have any delays since there's only one batch
132+
const duration = endTime - startTime
133+
expect(duration).toBeLessThan(50)
134+
})
135+
136+
it("should use default concurrency equal to batch size", async () => {
137+
const items = [1, 2, 3, 4]
138+
const processor = createSlowProcessor(30)
139+
140+
const startTime = Date.now()
141+
await processBatch(items, processor, { batchSize: 4 })
142+
const endTime = Date.now()
143+
144+
// All items in batch should process concurrently
145+
const duration = endTime - startTime
146+
expect(duration).toBeGreaterThan(25)
147+
expect(duration).toBeLessThan(60)
148+
})
149+
150+
it("should handle large batch sizes", async () => {
151+
const items = Array.from({ length: 100 }, (_, i) => i + 1)
152+
const processor = createProcessor()
153+
const results = await processBatch(items, processor, { batchSize: 25 })
154+
155+
expect(results).toHaveLength(100)
156+
expect(results[0]).toBe(2) // 1 * 2
157+
expect(results[99]).toBe(200) // 100 * 2
158+
})
159+
160+
it("should maintain order of results within batches", async () => {
161+
const items = [3, 1, 4, 2]
162+
const processor = createProcessor()
163+
const results = await processBatch(items, processor, { batchSize: 4 })
164+
165+
expect(results).toEqual([6, 2, 8, 4]) // Maintains input order
166+
})
167+
})
168+
169+
describe("processBatchWithErrorHandling", () => {
170+
let processedItems: number[] = []
171+
let errors: Array<{ error: Error; item: number }> = []
172+
173+
beforeEach(() => {
174+
processedItems = []
175+
errors = []
176+
})
177+
178+
const createProcessorWithErrors =
179+
(errorItems: number[] = []) =>
180+
async (item: number) => {
181+
if (errorItems.includes(item)) {
182+
throw new Error(`Error processing item ${item}`)
183+
}
184+
processedItems.push(item)
185+
return item * 2
186+
}
187+
188+
const errorHandler = (error: Error, item: number) => {
189+
errors.push({ error, item })
190+
}
191+
192+
it("should process items without errors normally", async () => {
193+
const items = [1, 2, 3, 4]
194+
const processor = createProcessorWithErrors([])
195+
const results = await processBatchWithErrorHandling(items, processor, {
196+
batchSize: 2,
197+
onError: errorHandler,
198+
})
199+
200+
expect(results).toEqual([2, 4, 6, 8])
201+
expect(errors).toHaveLength(0)
202+
expect(processedItems).toEqual([1, 2, 3, 4])
203+
})
204+
205+
it("should handle errors and continue processing other items", async () => {
206+
const items = [1, 2, 3, 4]
207+
const processor = createProcessorWithErrors([2, 4])
208+
const results = await processBatchWithErrorHandling(items, processor, {
209+
batchSize: 2,
210+
onError: errorHandler,
211+
})
212+
213+
// Results should contain successful results and Error objects
214+
expect(results).toHaveLength(4)
215+
expect(results[0]).toBe(2) // Item 1 successful
216+
expect(results[1]).toBeInstanceOf(Error) // Item 2 failed
217+
expect(results[2]).toBe(6) // Item 3 successful
218+
expect(results[3]).toBeInstanceOf(Error) // Item 4 failed
219+
220+
expect(errors).toHaveLength(2)
221+
expect(errors[0].item).toBe(2)
222+
expect(errors[1].item).toBe(4)
223+
expect(processedItems).toEqual([1, 3])
224+
})
225+
226+
it("should handle all items failing", async () => {
227+
const items = [1, 2, 3]
228+
const processor = createProcessorWithErrors([1, 2, 3])
229+
const results = await processBatchWithErrorHandling(items, processor, {
230+
batchSize: 2,
231+
onError: errorHandler,
232+
})
233+
234+
expect(results).toHaveLength(3)
235+
results.forEach(result => {
236+
expect(result).toBeInstanceOf(Error)
237+
})
238+
239+
expect(errors).toHaveLength(3)
240+
expect(processedItems).toHaveLength(0)
241+
})
242+
243+
it("should work without error handler", async () => {
244+
const items = [1, 2, 3]
245+
const processor = createProcessorWithErrors([2])
246+
const results = await processBatchWithErrorHandling(items, processor, { batchSize: 2 })
247+
248+
expect(results).toHaveLength(3)
249+
expect(results[0]).toBe(2)
250+
expect(results[1]).toBeInstanceOf(Error)
251+
expect(results[2]).toBe(6)
252+
})
253+
254+
it("should handle non-Error exceptions", async () => {
255+
const processor = async (item: number) => {
256+
if (item === 2) {
257+
throw "String error"
258+
}
259+
return item * 2
260+
}
261+
262+
const results = await processBatchWithErrorHandling([1, 2, 3], processor, {
263+
batchSize: 3,
264+
onError: errorHandler,
265+
})
266+
267+
expect(results).toHaveLength(3)
268+
expect(results[1]).toBeInstanceOf(Error)
269+
expect((results[1] as Error).message).toBe("String error")
270+
})
271+
272+
it("should respect concurrency and timing options", async () => {
273+
const items = [1, 2, 3, 4, 5, 6]
274+
const processor = async (item: number) => {
275+
await new Promise(resolve => setTimeout(resolve, 20))
276+
if (item === 3) throw new Error("Test error")
277+
return item * 2
278+
}
279+
280+
const startTime = Date.now()
281+
const results = await processBatchWithErrorHandling(items, processor, {
282+
batchSize: 6,
283+
concurrency: 2,
284+
onError: errorHandler,
285+
})
286+
const endTime = Date.now()
287+
288+
expect(results).toHaveLength(6)
289+
expect(errors).toHaveLength(1)
290+
expect(errors[0].item).toBe(3)
291+
292+
// Should take roughly 3 * 20ms for 3 sequential pairs
293+
const duration = endTime - startTime
294+
expect(duration).toBeGreaterThan(50)
295+
expect(duration).toBeLessThan(100)
296+
})
297+
298+
it("should handle empty array", async () => {
299+
const processor = createProcessorWithErrors([])
300+
const results = await processBatchWithErrorHandling([], processor, {
301+
batchSize: 2,
302+
onError: errorHandler,
303+
})
304+
305+
expect(results).toEqual([])
306+
expect(errors).toHaveLength(0)
307+
})
308+
})
309+
310+
describe("integration tests", () => {
311+
it("should handle complex real-world scenario", async () => {
312+
// Simulate processing API requests with rate limiting
313+
const apiRequests = Array.from({ length: 20 }, (_, i) => ({
314+
id: i + 1,
315+
data: `request-${i + 1}`,
316+
}))
317+
318+
const failingIds = [5, 12, 18]
319+
let requestCount = 0
320+
321+
const mockApiCall = async (request: { id: number; data: string }) => {
322+
requestCount++
323+
await new Promise(resolve => setTimeout(resolve, 10)) // Simulate API delay
324+
325+
if (failingIds.includes(request.id)) {
326+
throw new Error(`API error for request ${request.id}`)
327+
}
328+
329+
return { id: request.id, result: `processed-${request.data}` }
330+
}
331+
332+
const errors: Array<{ error: Error; item: any }> = []
333+
const errorHandler = (error: Error, item: any) => {
334+
errors.push({ error, item })
335+
}
336+
337+
const startTime = Date.now()
338+
const results = await processBatchWithErrorHandling(apiRequests, mockApiCall, {
339+
batchSize: 5,
340+
concurrency: 2,
341+
delay: 20, // Rate limiting delay
342+
onError: errorHandler,
343+
})
344+
const endTime = Date.now()
345+
346+
// Verify results
347+
expect(results).toHaveLength(20)
348+
expect(requestCount).toBe(20)
349+
expect(errors).toHaveLength(3)
350+
351+
// Check successful results
352+
const successfulResults = results.filter(r => !(r instanceof Error))
353+
expect(successfulResults).toHaveLength(17)
354+
355+
// Check failed requests
356+
expect(errors.map(e => e.item.id)).toEqual([5, 12, 18])
357+
358+
// Verify timing (4 batches * 20ms delay between + processing time)
359+
const duration = endTime - startTime
360+
expect(duration).toBeGreaterThan(60) // 3 delays + processing time
361+
expect(duration).toBeLessThan(400) // Very generous for CI and different system loads
362+
})
363+
})

0 commit comments

Comments
 (0)