Skip to content

Commit aea890c

Browse files
authored
feat(upload-client): add Queue helper to make queued uploads (#481)
* feat(upload-client): add `Queue` helper to make queued uploads * chore: fix lint warning * chore: refactor * chore: update readme
1 parent c614cac commit aea890c

File tree

5 files changed

+284
-16
lines changed

5 files changed

+284
-16
lines changed

packages/upload-client/README.md

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Node.js and browser.
2323
- [High-Level API](#high-level-api)
2424
- [Low-Level API](#low-level-api)
2525
- [Settings](#settings)
26+
- [Uploading queue](#uploading-queue)
2627
- [React Native](#react-native)
2728
- [Testing](#testing)
2829
- [Security issues](#security-issues)
@@ -54,19 +55,15 @@ Once the UploadClient instance is created, you can start using the wrapper to
5455
upload files from binary data:
5556

5657
```javascript
57-
client
58-
.uploadFile(fileData)
59-
.then(file => console.log(file.uuid))
58+
client.uploadFile(fileData).then((file) => console.log(file.uuid))
6059
```
6160

6261
Another option is uploading files from URL, via the `uploadFile` method:
6362

6463
```javascript
6564
const fileURL = 'https://example.com/file.jpg'
6665

67-
client
68-
.uploadFile(fileURL)
69-
.then(file => console.log(file.uuid))
66+
client.uploadFile(fileURL).then((file) => console.log(file.uuid))
7067
```
7168

7269
You can also use the `uploadFile` method to get previously uploaded files via
@@ -75,9 +72,7 @@ their UUIDs:
7572
```javascript
7673
const fileUUID = 'edfdf045-34c0-4087-bbdd-e3834921f890'
7774

78-
client
79-
.uploadFile(fileUUID)
80-
.then(file => console.log(file.uuid))
75+
client.uploadFile(fileUUID).then((file) => console.log(file.uuid))
8176
```
8277

8378
You can track uploading progress:
@@ -90,7 +85,7 @@ const onProgress = ({ isComputable, value }) => {
9085

9186
client
9287
.uploadFile(fileUUID, { onProgress })
93-
.then(file => console.log(file.uuid))
88+
.then((file) => console.log(file.uuid))
9489
```
9590

9691
Note that `isComputable` flag can be `false` is some cases of uploading from the URL.
@@ -105,8 +100,8 @@ const abortController = new AbortController()
105100

106101
client
107102
.uploadFile(fileUUID, { signal: abortController.signal })
108-
.then(file => console.log(file.uuid))
109-
.catch(error => {
103+
.then((file) => console.log(file.uuid))
104+
.catch((error) => {
110105
if (error.isCancel) {
111106
console.log(`File uploading was canceled.`)
112107
}
@@ -194,8 +189,8 @@ const onProgress = ({ isComputable, value }) => console.log(isComputable, value)
194189
const abortController = new AbortController()
195190

196191
base(fileData, { onProgress, signal: abortController.signal }) // fileData must be `Blob` or `File` or `Buffer`
197-
.then(data => console.log(data.file))
198-
.catch(error => {
192+
.then((data) => console.log(data.file))
193+
.catch((error) => {
199194
if (error.isCancel) {
200195
console.log(`File uploading was canceled.`)
201196
}
@@ -421,6 +416,55 @@ Non-string values will be converted to `string`. `undefined` values will be igno
421416
422417
See [docs][uc-file-metadata] and [REST API][uc-docs-metadata] for details.
423418
419+
### Uploading queue
420+
421+
If you're going to upload a lot of files at once, it's useful to do it in a queue. Otherwise, a large number of simultaneous requests can clog the internet channel and slow down the process.
422+
423+
To solve this problem, we provide a simple helper called `Queue`.
424+
425+
Here is an example of how to use it:
426+
427+
```typescript
428+
import { Queue, uploadFile } from '@uploadcare/upload-client'
429+
430+
// Create a queue with a limit of 10 concurrent requests.
431+
const queue = new Queue(10)
432+
433+
// Create an array containing 50 files.
434+
const files = [
435+
...Array(50)
436+
.fill(0)
437+
.map((_, idx) => Buffer.from(`content-${idx}`))
438+
]
439+
const promises = files.map((file, idx) => {
440+
const fileName = `file-${idx}.txt`
441+
return queue
442+
.add(() =>
443+
uploadFile(file, {
444+
publicKey: 'YOUR_PUBLIC_KEY',
445+
contentType: 'plain/text',
446+
fileName
447+
})
448+
)
449+
.then((fileInfo) =>
450+
console.log(
451+
`"File "${fileName}" has been successfully uploaded! You can access it at the following URL: "${fileInfo.cdnUrl}"`
452+
)
453+
)
454+
})
455+
456+
await Promise.all(promises)
457+
458+
console.log('Files have been successfully uploaded')
459+
```
460+
461+
You can pass any function that returns a promise to `queue.add`, and it will be executed concurrently.
462+
463+
`queue.add` returns a promise that mimics the one passed in, meaning it will resolve or reject with the corresponding values.
464+
465+
If the functionality of the built-in `Queue` is not sufficient for you, you can use any other third-party, more functional solution.
466+
467+
424468
## React Native
425469

426470
### Prepare

packages/upload-client/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export {
7777
CustomUserAgentOptions,
7878
GetUserAgentOptions
7979
} from '@uploadcare/api-client-utils'
80+
export { Queue } from './tools/Queue'
8081

8182
/* Types */
8283
export { Headers, ErrorRequestInfo } from './request/types'
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
type Task<T = unknown> = () => Promise<T>
2+
type Resolver = (value: unknown) => void
3+
type Rejector = (error: unknown) => void
4+
5+
export class Queue {
6+
#concurrency = 1
7+
#pending: Task[] = []
8+
#running = 0
9+
#resolvers: WeakMap<Task, Resolver> = new WeakMap()
10+
#rejectors: WeakMap<Task, Rejector> = new WeakMap()
11+
12+
constructor(concurrency: number) {
13+
this.#concurrency = concurrency
14+
}
15+
16+
#run() {
17+
const tasksLeft = this.#concurrency - this.#running
18+
for (let i = 0; i < tasksLeft; i++) {
19+
const task = this.#pending.shift()
20+
if (!task) {
21+
return
22+
}
23+
const resolver = this.#resolvers.get(task)
24+
const rejector = this.#rejectors.get(task)
25+
if (!resolver || !rejector)
26+
throw new Error(
27+
'Unexpected behavior: resolver or rejector is undefined'
28+
)
29+
this.#running += 1
30+
31+
task()
32+
.finally(() => {
33+
this.#resolvers.delete(task)
34+
this.#rejectors.delete(task)
35+
this.#running -= 1
36+
this.#run()
37+
})
38+
.then((value) => resolver(value))
39+
.catch((error) => rejector(error))
40+
}
41+
}
42+
43+
add<T>(task: Task<T>): Promise<T> {
44+
return new Promise((resolve, reject) => {
45+
this.#resolvers.set(task, resolve as Resolver)
46+
this.#rejectors.set(task, reject as Rejector)
47+
48+
this.#pending.push(task)
49+
this.#run()
50+
}) as Promise<T>
51+
}
52+
53+
get pending() {
54+
return this.#pending.length
55+
}
56+
57+
get running() {
58+
return this.#running
59+
}
60+
61+
set concurrency(value: number) {
62+
this.#concurrency = value
63+
this.#run()
64+
}
65+
66+
get concurrency() {
67+
return this.#concurrency
68+
}
69+
}

packages/upload-client/src/uploadFile/uploadFile.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { uploadFromUploaded } from './uploadFromUploaded'
44
import { uploadFromUrl } from './uploadFromUrl'
55

66
/* Types */
7-
import { CustomUserAgent, Metadata } from '@uploadcare/api-client-utils'
7+
import {
8+
CustomUserAgent,
9+
Metadata,
10+
StoreValue
11+
} from '@uploadcare/api-client-utils'
812
import { ProgressCallback, Url, Uuid } from '../api/types'
913
import { getFileSize } from '../tools/getFileSize'
1014
import { isFileData } from '../tools/isFileData'
@@ -13,7 +17,6 @@ import { UploadcareFile } from '../tools/UploadcareFile'
1317
import { SupportedFileInput } from '../types'
1418
import { isUrl, isUuid } from './types'
1519
import { uploadMultipart } from './uploadMultipart'
16-
import { StoreValue } from '@uploadcare/api-client-utils'
1720

1821
export type FileFromOptions = {
1922
publicKey: string
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { delay } from '@uploadcare/api-client-utils'
2+
import { Queue } from '../../src/tools/Queue'
3+
import { expect } from '@jest/globals'
4+
5+
const DELAY = 100
6+
const TIME_TOLERANCE = DELAY / 2
7+
8+
describe('Queue', () => {
9+
describe('#add', () => {
10+
it('should return promise resolved with the same value', async () => {
11+
expect.assertions(1)
12+
const queue = new Queue(1)
13+
const promise = queue.add(() => Promise.resolve('result'))
14+
await expect(promise).resolves.toBe('result')
15+
})
16+
17+
it('should return promise rejected with the same error', async () => {
18+
expect.assertions(1)
19+
const queue = new Queue(1)
20+
const promise = queue.add(() => Promise.reject('result'))
21+
await expect(promise).rejects.toBe('result')
22+
})
23+
})
24+
25+
it('should handle rejected promises', async () => {
26+
expect.assertions(2)
27+
const queue = new Queue(4)
28+
const promises = [
29+
queue.add(() => Promise.resolve()),
30+
queue.add(() => Promise.reject()),
31+
queue.add(() => Promise.resolve()),
32+
queue.add(() => Promise.reject())
33+
]
34+
expect(queue.running).toBe(4)
35+
await Promise.allSettled(promises)
36+
expect(queue.running).toBe(0)
37+
})
38+
39+
it('should run tasks in LILO sequense', async () => {
40+
expect.assertions(1)
41+
const queue = new Queue(1)
42+
const order: number[] = []
43+
const promises = [
44+
queue.add(() => Promise.resolve().then(() => order.push(1))),
45+
queue.add(() => Promise.resolve().then(() => order.push(2))),
46+
queue.add(() => Promise.resolve().then(() => order.push(3)))
47+
]
48+
await Promise.all(promises)
49+
await delay(0)
50+
expect(order).toEqual([1, 2, 3])
51+
})
52+
53+
it('should run tasks concurrently', async () => {
54+
expect.assertions(12)
55+
const queue = new Queue(4)
56+
const times: number[] = []
57+
const startTime = Date.now()
58+
const promises = Array.from({ length: 12 }).map(() => {
59+
return queue.add(() =>
60+
delay(DELAY).then(() => times.push(Date.now() - startTime))
61+
)
62+
})
63+
await Promise.all(promises)
64+
65+
expect(Math.abs(times[0] - DELAY * 1)).toBeLessThan(TIME_TOLERANCE)
66+
expect(Math.abs(times[1] - DELAY * 1)).toBeLessThan(TIME_TOLERANCE)
67+
expect(Math.abs(times[2] - DELAY * 1)).toBeLessThan(TIME_TOLERANCE)
68+
expect(Math.abs(times[3] - DELAY * 1)).toBeLessThan(TIME_TOLERANCE)
69+
70+
expect(Math.abs(times[4] - DELAY * 2)).toBeLessThan(TIME_TOLERANCE)
71+
expect(Math.abs(times[5] - DELAY * 2)).toBeLessThan(TIME_TOLERANCE)
72+
expect(Math.abs(times[6] - DELAY * 2)).toBeLessThan(TIME_TOLERANCE)
73+
expect(Math.abs(times[7] - DELAY * 2)).toBeLessThan(TIME_TOLERANCE)
74+
75+
expect(Math.abs(times[8] - DELAY * 3)).toBeLessThan(TIME_TOLERANCE)
76+
expect(Math.abs(times[9] - DELAY * 3)).toBeLessThan(TIME_TOLERANCE)
77+
expect(Math.abs(times[10] - DELAY * 3)).toBeLessThan(TIME_TOLERANCE)
78+
expect(Math.abs(times[11] - DELAY * 3)).toBeLessThan(TIME_TOLERANCE)
79+
})
80+
81+
describe('get pending', () => {
82+
it('should be able to get pending tasks count', async () => {
83+
expect.assertions(3)
84+
const queue = new Queue(2)
85+
expect(queue.pending).toBe(0)
86+
const promises = [
87+
queue.add(() => Promise.resolve()),
88+
queue.add(() => Promise.resolve()),
89+
queue.add(() => Promise.resolve())
90+
]
91+
// 2 task is running, 1 are pending
92+
expect(queue.pending).toBe(1)
93+
await Promise.all(promises)
94+
expect(queue.pending).toBe(0)
95+
})
96+
})
97+
98+
describe('get running', () => {
99+
it('should be able to get running tasks count', async () => {
100+
expect.assertions(3)
101+
const queue = new Queue(2)
102+
expect(queue.running).toBe(0)
103+
const promises = [
104+
queue.add(() => Promise.resolve()),
105+
queue.add(() => Promise.resolve()),
106+
queue.add(() => Promise.resolve())
107+
]
108+
expect(queue.running).toBe(2)
109+
await Promise.all(promises)
110+
expect(queue.running).toBe(0)
111+
})
112+
})
113+
114+
describe('get concurrency', () => {
115+
it('should be able to get concurrency', async () => {
116+
expect.assertions(1)
117+
const queue = new Queue(2)
118+
expect(queue.concurrency).toBe(2)
119+
})
120+
})
121+
describe('set concurrency', () => {
122+
it('should be able to change concurrency', async () => {
123+
expect.assertions(9)
124+
const queue = new Queue(1)
125+
const times: number[] = []
126+
const startTime = Date.now()
127+
const promises = Array.from({ length: 5 }).map(() => {
128+
return queue.add(() =>
129+
delay(DELAY).then(() => times.push(Date.now() - startTime))
130+
)
131+
})
132+
await delay(0)
133+
expect(queue.running).toBe(1)
134+
await promises[0]
135+
queue.concurrency = 2
136+
expect(queue.concurrency).toBe(2)
137+
expect(queue.running).toBe(2)
138+
await Promise.all(promises)
139+
140+
expect(Math.abs(times[0] - DELAY * 1)).toBeLessThan(TIME_TOLERANCE)
141+
142+
expect(Math.abs(times[1] - DELAY * 2)).toBeLessThan(TIME_TOLERANCE)
143+
expect(Math.abs(times[2] - DELAY * 2)).toBeLessThan(TIME_TOLERANCE)
144+
145+
expect(Math.abs(times[3] - DELAY * 3)).toBeLessThan(TIME_TOLERANCE)
146+
expect(Math.abs(times[4] - DELAY * 3)).toBeLessThan(TIME_TOLERANCE)
147+
148+
expect(queue.running).toBe(0)
149+
})
150+
})
151+
})

0 commit comments

Comments
 (0)