Skip to content

Commit 1db8174

Browse files
authored
feat: speed up file uploads (#1121)
1 parent 6cb93e6 commit 1db8174

16 files changed

Lines changed: 180 additions & 100 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ volumes:
100100
- `./public` - The folder where all the public assets are stored (must mount to `/zipline/public`)
101101
- `./themes` - The folder where all the custom themes are stored (must mount to `/zipline/themes`)
102102

103+
Temporary files default to `./uploads/.tmp`. Setting `CORE_TEMP_DIRECTORY` to another filesystem, such as tmpfs, can reduce local upload performance.
104+
103105
### Generating Secrets
104106

105107
```bash
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- CreateIndex
2+
CREATE INDEX "File_userId_size_idx" ON "public"."File"("userId", "size");

prisma/schema.prisma

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ model Zipline {
1717
1818
coreReturnHttpsUrls Boolean @default(false)
1919
coreDefaultDomain String?
20-
coreTempDirectory String // default join(tmpdir(), 'zipline')
20+
coreTempDirectory String // default resolve('./uploads/.tmp')
2121
coreTrustProxy Boolean @default(false)
2222
2323
chunksEnabled Boolean @default(true)
@@ -298,6 +298,7 @@ model File {
298298
thumbnail Thumbnail?
299299
300300
@@index([name])
301+
@@index([userId, size])
301302
@@index([folderId, createdAt])
302303
}
303304

src/lib/api/upload.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,30 +33,26 @@ export function getExtension(filename: string, override?: string) {
3333
}
3434

3535
export async function checkQuota(
36-
user: User | null,
36+
user: Pick<User, 'id' | 'quota'> | null,
3737
newSize: number,
3838
fileCount: number,
3939
): Promise<true | string> {
4040
if (!user?.quota) return true;
4141

42-
const stats = await prisma.file.aggregate({
43-
where: {
44-
userId: user.id,
45-
},
46-
_sum: {
47-
size: true,
48-
},
49-
_count: {
50-
_all: true,
51-
},
52-
});
42+
if (user.quota.filesQuota === 'BY_BYTES') {
43+
const stats = await prisma.file.aggregate({
44+
where: { userId: user.id },
45+
_sum: { size: true },
46+
});
5347

54-
const aggSize = stats?._sum?.size ? stats._sum.size : 0n;
48+
if (Number(stats._sum.size ?? 0n) + newSize > bytes(user.quota.maxBytes!))
49+
return `uploading will exceed your storage quota of ${user.quota.maxBytes}`;
5550

56-
if (user.quota.filesQuota === 'BY_BYTES' && Number(aggSize) + newSize > bytes(user.quota.maxBytes!))
57-
return `uploading will exceed your storage quota of ${user.quota.maxFiles} files`;
51+
return true;
52+
}
5853

59-
if (user.quota.filesQuota === 'BY_FILES' && stats?._count?._all + fileCount > user.quota.maxFiles!)
54+
const count = await prisma.file.count({ where: { userId: user.id } });
55+
if (count + fileCount > user.quota.maxFiles!)
6056
return `uploading will exceed your file count quota of ${user.quota.maxFiles} files`;
6157

6258
return true;
@@ -81,14 +77,16 @@ export async function getFilename(
8177
originalName: string,
8278
extension: string,
8379
override?: string,
80+
reservedNames?: Set<string>,
8481
): Promise<{ error: string } | { fileName: string }> {
8582
try {
8683
let fileName = override ? sanitizeFilename(override) : formatFileName(format, originalName);
8784

8885
if (!fileName) return { error: 'invalid file name' };
8986

9087
let fullFileName = `${fileName}${extension}`;
91-
let existing = await prisma.file.findFirst({ where: { name: fullFileName } });
88+
let existing =
89+
reservedNames?.has(fullFileName) || (await prisma.file.findFirst({ where: { name: fullFileName } }));
9290

9391
if (existing && (override || format === 'name')) {
9492
return { error: 'file with the same name already exists' };
@@ -101,9 +99,11 @@ export async function getFilename(
10199
if (!fileName) return { error: 'invalid file name' };
102100

103101
fullFileName = `${fileName}${extension}`;
104-
existing = await prisma.file.findFirst({ where: { name: fullFileName } });
102+
existing =
103+
reservedNames?.has(fullFileName) || (await prisma.file.findFirst({ where: { name: fullFileName } }));
105104
}
106105

106+
reservedNames?.add(fullFileName);
107107
return { fileName };
108108
} catch (e) {
109109
logger.warn(`error generating file name: ${e}`);

src/lib/config/read/db.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { prisma } from '@/lib/db';
2-
import { tmpdir } from 'os';
3-
import { join } from 'path';
2+
import { resolve } from 'path';
43

54
export const DATABASE_TO_PROP = {
65
coreReturnHttpsUrls: 'core.returnHttpsUrls',
@@ -154,7 +153,7 @@ export async function readDatabaseSettings() {
154153
if (!ziplineTable) {
155154
ziplineTable = await prisma.zipline.create({
156155
data: {
157-
coreTempDirectory: join(tmpdir(), 'zipline'),
156+
coreTempDirectory: resolve('./uploads/.tmp'),
158157
},
159158
omit: {
160159
createdAt: true,

src/lib/config/validate.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { tmpdir } from 'os';
2-
import { join, resolve } from 'path';
1+
import { resolve } from 'path';
32
import { z } from 'zod';
43
import { log } from '../logger';
54
import { ParsedConfig } from './read';
@@ -99,7 +98,7 @@ export const schema = z.object({
9998
tempDirectory: z
10099
.string()
101100
.transform((s) => resolve(s))
102-
.default(join(tmpdir(), 'zipline')),
101+
.default(resolve('./uploads/.tmp')),
103102
trustProxy: z.boolean().default(false),
104103

105104
databaseUrl: z.url(),

src/lib/datasource/Local.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,15 @@ export class LocalDatasource extends Datasource {
101101
}
102102

103103
public async totalSize(): Promise<number> {
104-
const files = await readdir(this.dir);
105-
const sizes = await Promise.all(files.map((file) => this.size(file)));
104+
const files = (await readdir(this.dir, { withFileTypes: true })).filter((file) => file.isFile());
105+
const sizes = await Promise.all(files.map((file) => this.size(file.name)));
106106

107107
return sizes.reduce((a, b) => a + b, 0);
108108
}
109109

110110
public async clear(): Promise<void> {
111-
for (const file of await readdir(this.dir)) {
112-
await rm(join(this.dir, file));
111+
for (const file of await readdir(this.dir, { withFileTypes: true })) {
112+
if (file.isFile()) await rm(join(this.dir, file.name));
113113
}
114114
}
115115

src/lib/datasource/S3.ts

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import {
1313
UploadPartCopyCommand,
1414
} from '@aws-sdk/client-s3';
1515
import { NodeHttpHandler } from '@smithy/node-http-handler';
16+
import { Upload } from '@aws-sdk/lib-storage';
1617
import { createReadStream } from 'fs';
18+
import { stat } from 'fs/promises';
1719
import { Agent as HttpAgent } from 'http';
1820
import { Agent as HttpsAgent } from 'https';
1921
import { Readable } from 'stream';
@@ -168,36 +170,49 @@ export class S3Datasource extends Datasource {
168170
}
169171

170172
public async put(file: string, data: Buffer | string, options: PutOptions = {}): Promise<void> {
171-
let command = new PutObjectCommand({
172-
Bucket: this.options.bucket,
173-
Key: this.key(file),
174-
Body: data,
175-
...(options.mimetype ? { ContentType: options.mimetype } : {}),
176-
});
173+
try {
174+
if (typeof data === 'string') {
175+
const size = await stat(data).then((file) => file.size);
176+
if (size > 25 * 1024 * 1024) {
177+
// 25mb
178+
this.logger.debug('putting object with multipart upload', { file, key: this.key(file) });
179+
180+
try {
181+
const upload = new Upload({
182+
client: this.client,
183+
params: {
184+
Bucket: this.options.bucket,
185+
Key: this.key(file),
186+
Body: createReadStream(data),
187+
...(options.mimetype ? { ContentType: options.mimetype } : {}),
188+
},
189+
leavePartsOnError: false,
190+
});
191+
192+
await upload.done();
193+
return;
194+
} catch (error) {
195+
this.logger.warn('multipart upload failed, retrying with a single request', {
196+
error: error instanceof Error ? error.message : error,
197+
});
198+
}
199+
}
200+
}
177201

178-
if (typeof data === 'string') {
179-
const readStream = createReadStream(data);
180-
command = new PutObjectCommand({
202+
const command = new PutObjectCommand({
181203
Bucket: this.options.bucket,
182204
Key: this.key(file),
183-
Body: readStream,
205+
Body: typeof data === 'string' ? createReadStream(data) : data,
184206
...(options.mimetype ? { ContentType: options.mimetype } : {}),
185207
});
186-
187-
this.logger.debug('putting object from stream', { file, key: this.key(file) });
188-
}
189-
190-
try {
191208
const res = await this.client.send(command);
192209

193210
if (!isOk(res.$metadata.httpStatusCode || 0)) {
194-
this.logger.error(
195-
'there was an error while putting object',
196-
res.$metadata as Record<string, unknown>,
197-
);
211+
throw new Error(`S3 put failed with status ${res.$metadata.httpStatusCode ?? 'unknown'}`);
198212
}
199213
} catch (e) {
200214
this.logger.error('there was an error while putting object', e as Record<string, unknown>);
215+
throw e;
201216
}
202217
}
203218

src/lib/db/models/zipline.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
import { tmpdir } from 'os';
21
import { prisma } from '..';
3-
import { join } from 'path';
2+
import { resolve } from 'path';
43

54
export async function getZipline() {
65
const zipline = await prisma.zipline.findFirst();
76
if (!zipline) {
8-
const tmp = join(tmpdir(), 'zipline');
97
return prisma.zipline.create({
108
data: {
11-
coreTempDirectory: tmp,
9+
coreTempDirectory: resolve('./uploads/.tmp'),
1210
},
1311
});
1412
}

src/lib/mapConcurrent.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export async function mapConcurrent<T, R>(
2+
values: T[],
3+
concurrency: number,
4+
map: (value: T, index: number) => Promise<R>,
5+
): Promise<R[]> {
6+
const results = Array<R>(values.length);
7+
let next = 0;
8+
let failed = false;
9+
let failure: unknown;
10+
11+
async function worker() {
12+
while (!failed && next < values.length) {
13+
const index = next++;
14+
try {
15+
results[index] = await map(values[index], index);
16+
} catch (error) {
17+
failed = true;
18+
failure = error;
19+
}
20+
}
21+
}
22+
23+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker));
24+
if (failed) throw failure;
25+
26+
return results;
27+
}

0 commit comments

Comments
 (0)