Skip to content

Commit ebb1b3d

Browse files
committed
Polish configurable upload PR
1 parent ef42f62 commit ebb1b3d

10 files changed

Lines changed: 93 additions & 75 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ node_modules
99

1010
# unit tests
1111
coverage
12+
test/environment/docker-dev-volumes
1213

1314
# macOS
1415
.DS_Store
1516

1617
# ignore npm lock
1718
package-json.lock
18-
.npmrc
19+
.npmrc

.prettierignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
test/environment/docker-dev-volumes/
1+
test/environment/docker-dev-volumes
2+
test/environment/docker-dev-volumes/**

CHANGELOG.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,8 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel
2828
- Templates: Added `setTemplateAsDefault` use case and repository method to support Dataverse endpoint `POST /dataverses/{id}/template/default/{templateId}`.
2929
- Templates: Added `unsetTemplateAsDefault` use case and repository method to support Dataverse endpoint `DELETE /dataverses/{id}/template/default`.
3030
- New Use Case: [Update Terms of Access](./docs/useCases.md#update-terms-of-access).
31-
- Files: Added `FilesConfig` class for configuring file upload behavior at runtime, including:
32-
- `useS3Tagging`: Option to disable S3 object tagging (`x-amz-tagging` header) for S3-compatible storage that doesn't support tagging. Default: `true`.
33-
- `maxMultipartRetries`: Configurable maximum retries for multipart upload parts. Default: `5`.
34-
- `fileUploadTimeoutMs`: Configurable timeout for file upload operations. Default: `60000`.
31+
- Files: Direct uploads now forward the `tagging` value returned by the upload destination response as the `x-amz-tagging` header for single-part uploads.
32+
- Files: Added a `DirectUploadClientConfig` object for configuring multipart upload retries and upload timeout.
3533
- Guestbooks: Added use cases and repository support for guestbook creation, listing, and enabling/disabling.
3634
- Guestbooks: Added dataset-level guestbook assignment and removal support via `assignDatasetGuestbook` (`PUT /api/datasets/{identifier}/guestbook`) and `removeDatasetGuestbook` (`DELETE /api/datasets/{identifier}/guestbook`).
3735
- Datasets/Guestbooks: Added `guestbookId` in `getDataset` responses.

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616
"test:coverage": "jest --coverage -c jest.config.ts",
1717
"test:coverage:check": "jest --coverage --ci --config jest.config.ts",
1818
"lint": "npm run lint:eslint && npm run lint:prettier",
19-
"lint:fix": "eslint --fix --ext .ts ./src --ignore-path .gitignore ./src",
20-
"lint:eslint": "eslint --ignore-path .gitignore ./src",
21-
"lint:prettier": "prettier --check '**/*.(yml|json|md)'",
22-
"format": "prettier --write './src/**/*.{js,ts,md,json,yml,md}' --config ./.prettierrc",
19+
"lint:fix": "eslint --fix --ext .ts --ignore-path .gitignore .",
20+
"lint:eslint": "eslint --ext .ts --ignore-path .gitignore .",
21+
"lint:prettier": "prettier --check '*.{yml,json,md}' 'docs/**/*.{yml,json,md}' 'test/environment/docker-compose.yml'",
22+
"format": "prettier --write './src/**/*.{js,ts,md,json,yml}' '*.{yml,json,md}' 'docs/**/*.{yml,json,md}' 'test/environment/docker-compose.yml' --config ./.prettierrc",
2323
"typecheck": "tsc --noEmit",
2424
"prepare": "husky"
2525
},

src/files/infra/clients/DirectUploadClient.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,17 @@ export class DirectUploadClient implements IDirectUploadClient {
9999
const eTags: Record<number, string> = {}
100100
const maxRetries = this.maxMultipartRetries
101101
const limitConcurrency = pLimit(1)
102+
let uploadFailed = false
102103

103104
const uploadPart = async (
104105
destinationUrl: string,
105106
index: number,
106107
retries = 0
107108
): Promise<void> => {
109+
if (uploadFailed) {
110+
return
111+
}
112+
108113
const offset = index * partMaxSize
109114
const partSize = Math.min(partMaxSize, file.size - offset)
110115
const fileSlice = file.slice(offset, offset + partSize)
@@ -125,6 +130,8 @@ export class DirectUploadClient implements IDirectUploadClient {
125130
eTags[`${index + 1}`] = eTag
126131
} catch (error) {
127132
if (axios.isCancel(error)) {
133+
uploadFailed = true
134+
limitConcurrency.clearQueue()
128135
await this.abortMultipartUpload(file.name, datasetId, destination.abortEndpoint as string)
129136
throw new FileUploadCancelError(file.name, datasetId)
130137
}
@@ -133,6 +140,8 @@ export class DirectUploadClient implements IDirectUploadClient {
133140
await new Promise((resolve) => setTimeout(resolve, backoffDelay))
134141
await uploadPart(destinationUrl, index, retries + 1)
135142
} else {
143+
uploadFailed = true
144+
limitConcurrency.clearQueue()
136145
await this.abortMultipartUpload(file.name, datasetId, destination.abortEndpoint as string)
137146

138147
const errorMessage =

test/functional/collections/GetMyDataCollectionItems.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe('execute', () => {
7676
[PublicationStatus.Deaccessioned],
7777
undefined,
7878
undefined,
79-
undefined
79+
testCollectionAlias
8080
)
8181
throw new Error('Use case should throw an error')
8282
} catch (error) {

test/functional/datasets/GetDatasetAvailableDatasetTypes.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('getDatasetAvailableDatasetTypes', () => {
2525
displayName: 'Dataset'
2626
}
2727
]
28-
expect(actualDatasetTypes).toEqual(expectedDatasetTypes)
28+
expect(actualDatasetTypes).toEqual(expect.arrayContaining(expectedDatasetTypes))
2929
})
3030
})
3131
})

test/unit/files/DirectUploadClient.test.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,12 @@ describe('uploadFile', () => {
8888
expect(actual).toEqual(testDestination.storageId)
8989
})
9090

91-
test('should include S3 tagging header by default', async () => {
91+
test('should include S3 tagging header when upload destination provides tagging', async () => {
9292
const filesRepositoryStub: IFilesRepository = {} as IFilesRepository
93-
const testDestination: FileUploadDestination = createSingleFileUploadDestinationModel()
93+
const testDestination: FileUploadDestination = {
94+
...createSingleFileUploadDestinationModel(),
95+
tagging: 'dv-state=temp'
96+
}
9497
filesRepositoryStub.getFileUploadDestination = jest.fn().mockResolvedValue(testDestination)
9598

9699
const axiosPutSpy = jest.spyOn(axios, 'put').mockResolvedValue(undefined)
@@ -113,14 +116,14 @@ describe('uploadFile', () => {
113116
)
114117
})
115118

116-
test('should not include S3 tagging header when useS3Tagging is false', async () => {
119+
test('should not include S3 tagging header when upload destination omits tagging', async () => {
117120
const filesRepositoryStub: IFilesRepository = {} as IFilesRepository
118121
const testDestination: FileUploadDestination = createSingleFileUploadDestinationModel()
119122
filesRepositoryStub.getFileUploadDestination = jest.fn().mockResolvedValue(testDestination)
120123

121124
const axiosPutSpy = jest.spyOn(axios, 'put').mockResolvedValue(undefined)
122125

123-
const sut = new DirectUploadClient(filesRepositoryStub, { useS3Tagging: false })
126+
const sut = new DirectUploadClient(filesRepositoryStub)
124127

125128
const progressMock = jest.fn()
126129
const abortController = new AbortController()
@@ -137,6 +140,29 @@ describe('uploadFile', () => {
137140
})
138141
)
139142
})
143+
144+
test('should use configured file upload timeout', async () => {
145+
const filesRepositoryStub: IFilesRepository = {} as IFilesRepository
146+
const testDestination: FileUploadDestination = createSingleFileUploadDestinationModel()
147+
filesRepositoryStub.getFileUploadDestination = jest.fn().mockResolvedValue(testDestination)
148+
149+
const axiosPutSpy = jest.spyOn(axios, 'put').mockResolvedValue(undefined)
150+
151+
const sut = new DirectUploadClient(filesRepositoryStub, { fileUploadTimeoutMs: 30_000 })
152+
153+
const progressMock = jest.fn()
154+
const abortController = new AbortController()
155+
156+
await sut.uploadFile(1, testFile, progressMock, abortController)
157+
158+
expect(axiosPutSpy).toHaveBeenCalledWith(
159+
testDestination.urls[0],
160+
expect.anything(),
161+
expect.objectContaining({
162+
timeout: 30_000
163+
})
164+
)
165+
})
140166
})
141167

142168
describe('Multiple parts file', () => {

test/unit/files/FilesConfig.test.ts

Lines changed: 0 additions & 59 deletions
This file was deleted.

test/unit/files/FilesRepository.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,48 @@ describe('FilesRepository', () => {
148148
expect(actual).toEqual(testMultipleFileUploadDestination)
149149
})
150150

151+
test('should return destination with tagging when single response includes tagging', async () => {
152+
const tagging = 'dv-state=temp'
153+
jest.spyOn(axios, 'get').mockResolvedValue({
154+
data: {
155+
status: 'OK',
156+
data: {
157+
...createSingleFileUploadDestinationPayload(),
158+
tagging
159+
}
160+
}
161+
})
162+
jest.spyOn(fs, 'statSync').mockReturnValue({ size: testFileSize } as fs.Stats)
163+
164+
const actual = await sut.getFileUploadDestination(testDatasetId, singlepartFile)
165+
166+
expect(actual).toEqual({
167+
...testSingleFileUploadDestination,
168+
tagging
169+
})
170+
})
171+
172+
test('should return destination with tagging when multipart response includes tagging', async () => {
173+
const tagging = 'dv-state=temp'
174+
jest.spyOn(axios, 'get').mockResolvedValue({
175+
data: {
176+
status: 'OK',
177+
data: {
178+
...createMultipartFileUploadDestinationPayload(),
179+
tagging
180+
}
181+
}
182+
})
183+
jest.spyOn(fs, 'statSync').mockReturnValue({ size: testFileSize } as fs.Stats)
184+
185+
const actual = await sut.getFileUploadDestination(testDatasetId, multipartFile)
186+
187+
expect(actual).toEqual({
188+
...testMultipleFileUploadDestination,
189+
tagging
190+
})
191+
})
192+
151193
test('should return error on repository read error', async () => {
152194
jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE)
153195
jest.spyOn(fs, 'statSync').mockReturnValue({ size: testFileSize } as fs.Stats)

0 commit comments

Comments
 (0)