@eluvio/elv-client-js 4.2.25 → 4.2.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/src/EthClient.js +2 -1
- package/src/client/ABRPublishing.js +33 -19
- package/src/client/ContentAccess.js +70 -1
- package/src/client/Files.js +425 -219
- package/src/client/LiveStream.js +47 -21
- package/utilities/MediaIngest.js +424 -0
- package/utilities/MigrateEncryptionConkForNewUser.js +62 -0
- package/utilities/ObjectAddFiles.js +29 -9
- package/utilities/README_INGEST.md +332 -0
- package/utilities/lib/abr_profiles/abr_profile_4k_all.json +181 -0
- package/utilities/lib/concerns/Client.js +71 -10
- package/utilities/lib/concerns/CloudAccess.js +2 -2
- package/utilities/lib/concerns/CloudFile.js +16 -8
- package/utilities/lib/concerns/LocalFile.js +2 -1
- package/utilities/lib/data/network_specs.json +13 -0
- package/utilities/lib/models/CredentialSet.js +26 -7
- package/utilities/tests/unit/lib/concerns/Client.test.js +96 -0
package/src/client/Files.js
CHANGED
|
@@ -23,6 +23,13 @@ const {
|
|
|
23
23
|
ValidateParameters
|
|
24
24
|
} = require("../Validation");
|
|
25
25
|
|
|
26
|
+
const JobStatus = Object.freeze({
|
|
27
|
+
STOPPED: "STOPPED",
|
|
28
|
+
FAILED: "FAILED",
|
|
29
|
+
IN_PROGRESS:"IN_PROGRESS",
|
|
30
|
+
COMPLETED: "COMPLETED"
|
|
31
|
+
});
|
|
32
|
+
|
|
26
33
|
|
|
27
34
|
/* Files */
|
|
28
35
|
|
|
@@ -52,8 +59,9 @@ exports.ListFiles = async function({libraryId, objectId, path = "", versionHash,
|
|
|
52
59
|
});
|
|
53
60
|
};
|
|
54
61
|
|
|
62
|
+
|
|
55
63
|
/**
|
|
56
|
-
*
|
|
64
|
+
* Upload or resume copy/reference files from S3 to a content object
|
|
57
65
|
*
|
|
58
66
|
* S3 authentication is done by either providing an access key and secret or a presigned URL. The credentials will not be stored (neither in the client nor in the Fabric)
|
|
59
67
|
*
|
|
@@ -61,12 +69,12 @@ exports.ListFiles = async function({libraryId, objectId, path = "", versionHash,
|
|
|
61
69
|
*
|
|
62
70
|
* Expected format of fileInfo:
|
|
63
71
|
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
[
|
|
73
|
+
{
|
|
74
|
+
path: string,
|
|
75
|
+
source: string // either a full path e.g. "s3://BUCKET_NAME/path..." or just the path part without "s3://BUCKET_NAME/"
|
|
76
|
+
}
|
|
77
|
+
]
|
|
70
78
|
*
|
|
71
79
|
* @memberof module:ElvClient/Files+Parts
|
|
72
80
|
* @methodGroup Files
|
|
@@ -74,14 +82,15 @@ exports.ListFiles = async function({libraryId, objectId, path = "", versionHash,
|
|
|
74
82
|
* @param {string} libraryId - ID of the library
|
|
75
83
|
* @param {string} objectId - ID of the object
|
|
76
84
|
* @param {string} writeToken - Write token of the draft
|
|
77
|
-
* @param {string} region - AWS region to use
|
|
78
|
-
* @param {string} bucket - AWS bucket to use
|
|
85
|
+
* @param {string=} region - AWS region to use
|
|
86
|
+
* @param {string=} bucket - AWS bucket to use
|
|
79
87
|
* @param {Array<Object>} fileInfo - List of files to reference/copy
|
|
80
|
-
* @param {string} accessKey - AWS access key
|
|
81
|
-
* @param {string} secret - AWS secret
|
|
88
|
+
* @param {string=} accessKey - AWS access key
|
|
89
|
+
* @param {string=} secret - AWS secret
|
|
82
90
|
* @param {string=} signedUrl
|
|
83
91
|
* @param {string} encryption="none" - Encryption for uploaded files (copy only) - cgck | none
|
|
84
92
|
* @param {boolean} copy=false - If true, will copy the data from S3 into the fabric. Otherwise, a reference to the content will be made.
|
|
93
|
+
* @param {boolean} resume=false - If true, will resume the file jobs stopped
|
|
85
94
|
* @param {function=} callback - If specified, will be periodically called with current upload status
|
|
86
95
|
* - Arguments (copy): { done: boolean, uploaded: number, total: number, uploadedFiles: number, totalFiles: number, fileStatus: Object }
|
|
87
96
|
* - Arguments (reference): { done: boolean, uploadedFiles: number, totalFiles: number }
|
|
@@ -96,73 +105,54 @@ exports.UploadFilesFromS3 = async function({
|
|
|
96
105
|
accessKey,
|
|
97
106
|
secret,
|
|
98
107
|
signedUrl,
|
|
99
|
-
encryption="none",
|
|
100
|
-
copy=false,
|
|
108
|
+
encryption = "none",
|
|
109
|
+
copy = false,
|
|
110
|
+
resume = false,
|
|
101
111
|
callback
|
|
102
112
|
}) {
|
|
103
|
-
ValidateParameters({libraryId, objectId});
|
|
113
|
+
ValidateParameters({ libraryId, objectId });
|
|
104
114
|
ValidateWriteToken(writeToken);
|
|
105
115
|
|
|
106
116
|
const s3prefixRegex = /^s3:\/\/([^/]+)\//i; // for matching and extracting bucket name when full s3:// path is specified
|
|
107
117
|
// if fileInfo source paths start with s3://bucketName/, check against bucket arg passed in, and strip
|
|
108
118
|
for(let i = 0; i < fileInfo.length; i++) {
|
|
109
119
|
const fileSourcePath = fileInfo[i].source;
|
|
110
|
-
const s3prefixMatch =
|
|
120
|
+
const s3prefixMatch = s3prefixRegex.exec(fileSourcePath);
|
|
111
121
|
if(s3prefixMatch) {
|
|
112
122
|
const bucketName = s3prefixMatch[1];
|
|
113
123
|
if(bucketName !== bucket) {
|
|
114
124
|
throw Error("Full S3 file path \"" + fileSourcePath + "\" specified, but does not match provided bucket name '" + bucket + "'");
|
|
115
|
-
} else {
|
|
116
|
-
// strip prefix
|
|
117
|
-
fileInfo[i].source = fileSourcePath.replace(s3prefixRegex,"");
|
|
118
125
|
}
|
|
126
|
+
// strip prefix
|
|
127
|
+
fileInfo[i].source = fileSourcePath.replace(s3prefixRegex, "");
|
|
119
128
|
}
|
|
120
129
|
}
|
|
121
130
|
|
|
122
|
-
|
|
123
|
-
this.Log(`Copying files from S3: ${libraryId} ${objectId} ${writeToken}`);
|
|
124
|
-
} else {
|
|
125
|
-
this.Log(`Adding links to files in S3: ${libraryId} ${objectId} ${writeToken}`);
|
|
126
|
-
}
|
|
131
|
+
this.Log(`${resume ? "Resuming" : (copy ? "Copying" : "Referencing")} files from S3: ${libraryId} ${objectId} ${writeToken}`);
|
|
127
132
|
|
|
128
133
|
let encryption_key;
|
|
129
134
|
if(encryption === "cgck") {
|
|
130
|
-
let conk = await this.EncryptionConk({
|
|
131
|
-
|
|
132
|
-
objectId,
|
|
133
|
-
writeToken
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
conk = {
|
|
137
|
-
...conk,
|
|
138
|
-
secret_key: ""
|
|
139
|
-
};
|
|
140
|
-
|
|
135
|
+
let conk = await this.EncryptionConk({ libraryId, objectId, writeToken });
|
|
136
|
+
conk = { ...conk, secret_key: "" };
|
|
141
137
|
encryption_key = `kp__${this.utils.B58(Buffer.from(JSON.stringify(conk)))}`;
|
|
142
138
|
}
|
|
143
139
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
secret_access_key: secret
|
|
140
|
+
const cloudCredentials = signedUrl
|
|
141
|
+
? { signed_url: signedUrl }
|
|
142
|
+
: { access_key_id: accessKey, secret_access_key: secret };
|
|
143
|
+
|
|
144
|
+
const remoteAccess = {
|
|
145
|
+
protocol: "s3",
|
|
146
|
+
platform: "aws",
|
|
147
|
+
cloud_credentials: cloudCredentials
|
|
147
148
|
};
|
|
148
149
|
|
|
149
|
-
if(
|
|
150
|
-
|
|
151
|
-
signed_url: signedUrl
|
|
152
|
-
};
|
|
153
|
-
}
|
|
150
|
+
if(bucket) remoteAccess.path = bucket;
|
|
151
|
+
if(region) remoteAccess.storage_endpoint = {region};
|
|
154
152
|
|
|
155
153
|
const defaults = {
|
|
156
154
|
encryption_key,
|
|
157
|
-
access:
|
|
158
|
-
protocol: "s3",
|
|
159
|
-
platform: "aws",
|
|
160
|
-
path: bucket,
|
|
161
|
-
storage_endpoint: {
|
|
162
|
-
region
|
|
163
|
-
},
|
|
164
|
-
cloud_credentials: cloudCredentials
|
|
165
|
-
}
|
|
155
|
+
access: remoteAccess
|
|
166
156
|
};
|
|
167
157
|
|
|
168
158
|
const ops = fileInfo.map(info => {
|
|
@@ -190,55 +180,94 @@ exports.UploadFilesFromS3 = async function({
|
|
|
190
180
|
}
|
|
191
181
|
});
|
|
192
182
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
183
|
+
const trackUploadStatus = async ({ uploadId }) => {
|
|
184
|
+
// eslint-disable-next-line no-constant-condition
|
|
185
|
+
while(true) {
|
|
186
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
187
|
+
const status = await this.UploadStatus({ libraryId, objectId, writeToken, uploadId });
|
|
188
|
+
|
|
189
|
+
if(status.errors && status.errors.length >= 1) {
|
|
190
|
+
throw new Error(status.errors.join("\n"));
|
|
191
|
+
} else if(status.error) {
|
|
192
|
+
this.Log(`S3 file upload failed:\n${JSON.stringify(status, null, 2)}`);
|
|
193
|
+
throw new Error(status.error);
|
|
194
|
+
} else if(status.status.toLowerCase() === "failed") {
|
|
195
|
+
throw new Error("File upload failed");
|
|
196
|
+
}
|
|
199
197
|
|
|
200
|
-
|
|
198
|
+
let done = false;
|
|
199
|
+
if(copy) {
|
|
200
|
+
done = status.ingest_copy.done;
|
|
201
|
+
if(callback) {
|
|
202
|
+
const progress = status.ingest_copy.progress;
|
|
203
|
+
|
|
204
|
+
callback({
|
|
205
|
+
done,
|
|
206
|
+
uploaded: progress.bytes.completed,
|
|
207
|
+
total: progress.bytes.total,
|
|
208
|
+
uploadedFiles: progress.files.completed,
|
|
209
|
+
totalFiles: progress.files.total,
|
|
210
|
+
fileStatus: progress.files.details
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
done = status.add_reference.done;
|
|
215
|
+
if(callback) {
|
|
216
|
+
const progress = status.add_reference.progress;
|
|
217
|
+
|
|
218
|
+
callback({
|
|
219
|
+
done,
|
|
220
|
+
uploadedFiles: progress.completed,
|
|
221
|
+
totalFiles: progress.total,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
201
225
|
|
|
202
|
-
|
|
203
|
-
throw status.errors.join("\n");
|
|
204
|
-
} else if(status.error) {
|
|
205
|
-
this.Log(`S3 file upload failed:\n${JSON.stringify(status, null, 2)}`);
|
|
206
|
-
throw status.error;
|
|
207
|
-
} else if(status.status.toLowerCase() === "failed") {
|
|
208
|
-
throw "File upload failed";
|
|
226
|
+
if(done) break;
|
|
209
227
|
}
|
|
228
|
+
};
|
|
210
229
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
230
|
+
if(resume) {
|
|
231
|
+
const jobIds = await this.ListFilesJob({ libraryId, objectId, writeToken, encryption });
|
|
232
|
+
const jobsByStatus = jobIds.reduce((acc, job) => {
|
|
233
|
+
acc[job.status] = acc[job.status] || new Map();
|
|
234
|
+
acc[job.status].set(job.id, true);
|
|
235
|
+
return acc;
|
|
236
|
+
}, {});
|
|
214
237
|
|
|
215
|
-
|
|
216
|
-
|
|
238
|
+
const resumeAndTrack = async (jobIds) => {
|
|
239
|
+
const responses = await this.ResumeFileUploadJob({
|
|
240
|
+
libraryId,
|
|
241
|
+
objectId,
|
|
242
|
+
writeToken,
|
|
243
|
+
ops,
|
|
244
|
+
defaults,
|
|
245
|
+
encryption,
|
|
246
|
+
stoppedOrFailedJobIds: jobIds
|
|
247
|
+
});
|
|
248
|
+
await this.utils.LimitedMap(5, responses, async res => {
|
|
249
|
+
this.Log(`Tracking resumed job id: ${res.id}`);
|
|
250
|
+
await trackUploadStatus({ uploadId: res.id });
|
|
251
|
+
});
|
|
252
|
+
};
|
|
217
253
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
total: progress.bytes.total,
|
|
222
|
-
uploadedFiles: progress.files.completed,
|
|
223
|
-
totalFiles: progress.files.total,
|
|
224
|
-
fileStatus: progress.files.details
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
} else {
|
|
228
|
-
done = status.add_reference.done;
|
|
229
|
-
|
|
230
|
-
if(callback) {
|
|
231
|
-
const progress = status.add_reference.progress;
|
|
232
|
-
|
|
233
|
-
callback({
|
|
234
|
-
done,
|
|
235
|
-
uploadedFiles: progress.completed,
|
|
236
|
-
totalFiles: progress.total,
|
|
237
|
-
});
|
|
254
|
+
for(const status of [JobStatus.STOPPED, JobStatus.FAILED]) {
|
|
255
|
+
if(jobsByStatus[status]){
|
|
256
|
+
await resumeAndTrack([...jobsByStatus[status].keys()]);
|
|
238
257
|
}
|
|
239
258
|
}
|
|
240
259
|
|
|
241
|
-
|
|
260
|
+
const trackOnlyEntries = Object.entries(jobsByStatus)
|
|
261
|
+
.filter(([status]) => [JobStatus.IN_PROGRESS, JobStatus.COMPLETED].includes(status))
|
|
262
|
+
.flatMap(([status, jobs]) => [...jobs.keys()].map(jobId => ({ status, jobId })));
|
|
263
|
+
|
|
264
|
+
await this.utils.LimitedMap(5, trackOnlyEntries, async ({ status, jobId }) => {
|
|
265
|
+
this.Log(`Tracking ${status.toLowerCase()} job id: ${jobId}`);
|
|
266
|
+
await trackUploadStatus({ uploadId: jobId });
|
|
267
|
+
});
|
|
268
|
+
} else {
|
|
269
|
+
const { id: uploadId } = await this.CreateFileUploadJob({ libraryId, objectId, writeToken, ops, defaults });
|
|
270
|
+
await trackUploadStatus({ uploadId });
|
|
242
271
|
}
|
|
243
272
|
};
|
|
244
273
|
|
|
@@ -247,14 +276,14 @@ exports.UploadFilesFromS3 = async function({
|
|
|
247
276
|
*
|
|
248
277
|
* Expected format of fileInfo:
|
|
249
278
|
*
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
279
|
+
[
|
|
280
|
+
{
|
|
281
|
+
path: string,
|
|
282
|
+
mime_type: string,
|
|
283
|
+
size: number,
|
|
284
|
+
data: File | ArrayBuffer | Buffer | File Descriptor (Node)
|
|
285
|
+
}
|
|
286
|
+
]
|
|
258
287
|
*
|
|
259
288
|
*
|
|
260
289
|
* @memberof module:ElvClient/Files+Parts
|
|
@@ -264,11 +293,12 @@ exports.UploadFilesFromS3 = async function({
|
|
|
264
293
|
* @param {string} objectId - ID of the object
|
|
265
294
|
* @param {string} writeToken - Write token of the draft
|
|
266
295
|
* @param {Array<object>} fileInfo - List of files to upload, including their size, type, and contents
|
|
296
|
+
* @param {string} resume - Resume the upload jobs
|
|
267
297
|
* @param {string} encryption="none" - Encryption for uploaded files - cgck | none
|
|
268
298
|
* @param {function=} callback - If specified, will be called after each job segment is finished with the current upload progress
|
|
269
299
|
* - Format: {"filename1": {uploaded: number, total: number}, ...}
|
|
270
300
|
*/
|
|
271
|
-
exports.UploadFiles = async function({libraryId, objectId, writeToken, fileInfo, encryption="none", callback}) {
|
|
301
|
+
exports.UploadFiles = async function({libraryId, objectId, writeToken, fileInfo, resume=false,encryption="none", callback}) {
|
|
272
302
|
ValidateParameters({libraryId, objectId});
|
|
273
303
|
ValidateWriteToken(writeToken);
|
|
274
304
|
ValidatePresence("fileInfo", fileInfo);
|
|
@@ -311,160 +341,253 @@ exports.UploadFiles = async function({libraryId, objectId, writeToken, fileInfo,
|
|
|
311
341
|
|
|
312
342
|
this.Log(fileInfo);
|
|
313
343
|
|
|
314
|
-
|
|
344
|
+
// On a fresh upload nothing has happened yet, so 0% is accurate. On resume, the real starting
|
|
345
|
+
// point isn't known until job status is fetched below, so skip this to avoid a misleading
|
|
346
|
+
// initial 0% line ahead of the actual (possibly much higher) baseline.
|
|
347
|
+
if(callback && !resume) {
|
|
315
348
|
callback(progress);
|
|
316
349
|
}
|
|
317
350
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
objectId,
|
|
321
|
-
writeToken,
|
|
322
|
-
ops: fileInfo,
|
|
323
|
-
encryption
|
|
324
|
-
});
|
|
351
|
+
let idJobMap = new Map();
|
|
352
|
+
if(resume) {
|
|
353
|
+
const ids = await this.ListFilesJob({ libraryId, objectId, writeToken, encryption });
|
|
325
354
|
|
|
326
|
-
|
|
327
|
-
|
|
355
|
+
const jobsByStatus = ids.reduce((acc, job) => {
|
|
356
|
+
acc[job.status] = acc[job.status] || [];
|
|
357
|
+
acc[job.status].push(job.id);
|
|
358
|
+
return acc;
|
|
359
|
+
}, {});
|
|
328
360
|
|
|
329
|
-
|
|
330
|
-
|
|
361
|
+
const stoppedJobIds = jobsByStatus.STOPPED || [];
|
|
362
|
+
const failedJobIds = jobsByStatus.FAILED || [];
|
|
363
|
+
const inProgressJobIds = jobsByStatus.IN_PROGRESS || [];
|
|
331
364
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
365
|
+
const resumeJobIds = [...stoppedJobIds, ...failedJobIds, ...inProgressJobIds];
|
|
366
|
+
if(resumeJobIds.length === 0) {
|
|
367
|
+
this.Log("No job Ids to resume");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
335
370
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
371
|
+
const resumeJobs = async(jobs) => {
|
|
372
|
+
await this.ResumeFileUploadJob({
|
|
373
|
+
libraryId,
|
|
374
|
+
objectId,
|
|
375
|
+
writeToken,
|
|
376
|
+
ops: fileInfo,
|
|
377
|
+
encryption,
|
|
378
|
+
stoppedOrFailedJobIds: jobs
|
|
379
|
+
});
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// resume stopped and failed jobs
|
|
383
|
+
for(const jobsToResume of [stoppedJobIds, failedJobIds]){
|
|
384
|
+
if(jobsToResume.length > 0){
|
|
385
|
+
await resumeJobs(jobsToResume);
|
|
342
386
|
}
|
|
387
|
+
}
|
|
343
388
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
389
|
+
// for each uploadId, make a map containing jobIds
|
|
390
|
+
const uploadJobsResults = await this.utils.LimitedMap(5, resumeJobIds, async uploadId => {
|
|
391
|
+
const result = await this.ListFilesUploadJobs({
|
|
347
392
|
libraryId,
|
|
348
393
|
objectId,
|
|
349
394
|
writeToken,
|
|
350
|
-
|
|
351
|
-
|
|
395
|
+
jobId: uploadId,
|
|
396
|
+
encryption
|
|
352
397
|
});
|
|
398
|
+
return result.jobs;
|
|
399
|
+
});
|
|
400
|
+
resumeJobIds.forEach((uploadId, i) => idJobMap.set(uploadId, uploadJobsResults[i]));
|
|
401
|
+
|
|
402
|
+
} else {
|
|
403
|
+
const jobResponse = await this.CreateFileUploadJob({
|
|
404
|
+
libraryId,
|
|
405
|
+
objectId,
|
|
406
|
+
writeToken,
|
|
407
|
+
ops: fileInfo,
|
|
408
|
+
encryption
|
|
409
|
+
});
|
|
410
|
+
idJobMap.set(jobResponse.id, jobResponse.jobs);
|
|
411
|
+
}
|
|
353
412
|
|
|
354
|
-
|
|
355
|
-
|
|
413
|
+
// How far encryption can get ahead of upload (500 MB)
|
|
414
|
+
const bufferSize = 500 * 1024 * 1024;
|
|
356
415
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
416
|
+
// Process each uploadId and its jobs
|
|
417
|
+
for(const [uploadId, jobs] of idJobMap.entries()) {
|
|
418
|
+
this.Log(`Upload ID: ${uploadId}`);
|
|
419
|
+
this.Log(jobs.length);
|
|
420
|
+
|
|
421
|
+
// Each upload ID gets its own counters
|
|
422
|
+
let prepared = 0;
|
|
423
|
+
let uploaded = 0;
|
|
424
|
+
let jobSpecs = [];
|
|
425
|
+
|
|
426
|
+
// `${jobId}:${path}` pairs already reflected in progress by the resume baseline scan below,
|
|
427
|
+
// so the normal per-chunk completion credit further down doesn't double-count them
|
|
428
|
+
// It stores the jobId whose remaining bytes = 0
|
|
429
|
+
let alreadyCreditedKeys = new Set();
|
|
430
|
+
|
|
431
|
+
if(resume && callback) {
|
|
432
|
+
// Establish the true resume baseline across ALL chunks up front, before showing any
|
|
433
|
+
// progress. Crediting chunks one at a time as PrepareJobs reaches them (sequentially, each
|
|
434
|
+
// with its own round trip) would make an already-mostly-done file look like it's slowly
|
|
435
|
+
// re-climbing from scratch instead of immediately reflecting what's already been received.
|
|
436
|
+
await this.utils.LimitedMap(5, jobs, async jobId => {
|
|
437
|
+
const job = await this.UploadJobStatus({ libraryId, objectId, writeToken, uploadId, jobId });
|
|
438
|
+
|
|
439
|
+
for(const fileInfo of job.files) {
|
|
440
|
+
// When encrypted, the top-level rem/skip are sentinel (-1) - the real, already-received
|
|
441
|
+
// byte counts for this chunk are under `encrypted` (same lookup UploadFileData uses)
|
|
442
|
+
const fileStatus = (encryption && encryption !== "none") ? fileInfo.encrypted : fileInfo;
|
|
443
|
+
if(fileStatus.rem === 0) {
|
|
444
|
+
alreadyCreditedKeys.add(`${jobId}:${fileInfo.path}`);
|
|
445
|
+
progress[fileInfo.path] = {
|
|
446
|
+
...progress[fileInfo.path],
|
|
447
|
+
uploaded: progress[fileInfo.path].uploaded + fileInfo.len
|
|
448
|
+
};
|
|
449
|
+
}
|
|
365
450
|
}
|
|
451
|
+
});
|
|
366
452
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
453
|
+
callback(progress);
|
|
454
|
+
}
|
|
370
455
|
|
|
371
|
-
|
|
456
|
+
// Insert the data to upload into the job spec, encrypting if necessary
|
|
457
|
+
const PrepareJobs = async () => {
|
|
458
|
+
for(let j = 0; j < jobs.length; j++) {
|
|
459
|
+
while(prepared - uploaded > bufferSize) {
|
|
460
|
+
// Wait for more data to be uploaded
|
|
461
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
462
|
+
}
|
|
372
463
|
|
|
373
|
-
|
|
374
|
-
|
|
464
|
+
// Retrieve job info
|
|
465
|
+
const jobId = jobs[j];
|
|
466
|
+
let job = await this.UploadJobStatus({
|
|
467
|
+
libraryId,
|
|
468
|
+
objectId,
|
|
469
|
+
writeToken,
|
|
470
|
+
uploadId,
|
|
471
|
+
jobId
|
|
472
|
+
});
|
|
375
473
|
|
|
376
|
-
|
|
474
|
+
for(let f = 0; f < job.files.length; f++) {
|
|
475
|
+
const fileInfo = job.files[f];
|
|
476
|
+
|
|
477
|
+
let data;
|
|
478
|
+
if(typeof fileDataMap[fileInfo.path] === "number") {
|
|
479
|
+
// File descriptor - Read data from file
|
|
480
|
+
data = Buffer.alloc(fileInfo.len);
|
|
481
|
+
fs.readSync(fileDataMap[fileInfo.path], data, 0, fileInfo.len, fileInfo.off);
|
|
482
|
+
} else {
|
|
483
|
+
// Full data - Slice requested chunk
|
|
484
|
+
data = fileDataMap[fileInfo.path].slice(fileInfo.off, fileInfo.off + fileInfo.len);
|
|
485
|
+
}
|
|
377
486
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
};
|
|
487
|
+
if(encryption === "cgck") {
|
|
488
|
+
data = await this.Crypto.Encrypt(conk, data);
|
|
489
|
+
}
|
|
382
490
|
|
|
383
|
-
|
|
384
|
-
while(!jobSpecs[j]) {
|
|
385
|
-
// Wait for more jobs to be prepared
|
|
386
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
387
|
-
}
|
|
491
|
+
job.files[f].data = data;
|
|
388
492
|
|
|
389
|
-
|
|
390
|
-
|
|
493
|
+
prepared += fileInfo.len;
|
|
494
|
+
}
|
|
391
495
|
|
|
392
|
-
|
|
393
|
-
for(let f = 0; f < files.length; f++) {
|
|
394
|
-
const fileInfo = files[f];
|
|
496
|
+
jobSpecs[j] = job;
|
|
395
497
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
try {
|
|
400
|
-
await this.UploadFileData({
|
|
401
|
-
libraryId,
|
|
402
|
-
objectId,
|
|
403
|
-
writeToken,
|
|
404
|
-
uploadId: id,
|
|
405
|
-
jobId,
|
|
406
|
-
filePath: fileInfo.path,
|
|
407
|
-
fileData: fileInfo.data,
|
|
408
|
-
encryption
|
|
409
|
-
});
|
|
498
|
+
// Wait for a bit to let upload start
|
|
499
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
500
|
+
}
|
|
410
501
|
|
|
411
|
-
|
|
412
|
-
} catch(error) {
|
|
413
|
-
this.Log(error, true);
|
|
502
|
+
};
|
|
414
503
|
|
|
415
|
-
|
|
504
|
+
const UploadJob = async (jobId, j) => {
|
|
505
|
+
while(!jobSpecs[j]) {
|
|
506
|
+
// Wait for more jobs to be prepared
|
|
507
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
508
|
+
}
|
|
416
509
|
|
|
417
|
-
|
|
418
|
-
|
|
510
|
+
const jobSpec = jobSpecs[j];
|
|
511
|
+
const files = jobSpec.files;
|
|
512
|
+
|
|
513
|
+
// Upload each item
|
|
514
|
+
for(let f = 0; f < files.length; f++) {
|
|
515
|
+
const fileInfo = files[f];
|
|
516
|
+
|
|
517
|
+
let retries = 0;
|
|
518
|
+
let succeeded = false;
|
|
519
|
+
do {
|
|
520
|
+
try {
|
|
521
|
+
await this.UploadFileData({
|
|
522
|
+
libraryId,
|
|
523
|
+
objectId,
|
|
524
|
+
writeToken,
|
|
525
|
+
uploadId,
|
|
526
|
+
jobId,
|
|
527
|
+
filePath: fileInfo.path,
|
|
528
|
+
fileData: fileInfo.data,
|
|
529
|
+
encryption
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
succeeded = true;
|
|
533
|
+
} catch(error) {
|
|
534
|
+
this.Log(error, true);
|
|
535
|
+
|
|
536
|
+
retries += 1;
|
|
537
|
+
|
|
538
|
+
if(retries >= 10) {
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
await new Promise(resolve => setTimeout(resolve, 10 * retries * 1000));
|
|
419
543
|
}
|
|
544
|
+
} while(!succeeded && retries < 10);
|
|
420
545
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
} while(!succeeded && retries < 10);
|
|
546
|
+
delete jobSpecs[j].files[f].data;
|
|
547
|
+
uploaded += fileInfo.len;
|
|
424
548
|
|
|
425
|
-
|
|
426
|
-
|
|
549
|
+
if(callback && !alreadyCreditedKeys.has(`${jobId}:${fileInfo.path}`)) {
|
|
550
|
+
progress[fileInfo.path] = {
|
|
551
|
+
...progress[fileInfo.path],
|
|
552
|
+
uploaded: progress[fileInfo.path].uploaded + fileInfo.len
|
|
553
|
+
};
|
|
427
554
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
...progress[fileInfo.path],
|
|
431
|
-
uploaded: progress[fileInfo.path].uploaded + fileInfo.len
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
callback(progress);
|
|
555
|
+
callback(progress);
|
|
556
|
+
}
|
|
435
557
|
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// Preparing jobs is done asynchronously
|
|
561
|
+
PrepareJobs().catch(e => { throw e; });
|
|
562
|
+
|
|
563
|
+
// Upload the first several chunks in sequence, to determine average upload rate
|
|
564
|
+
const rateTestJobs = Math.min(3, jobs.length);
|
|
565
|
+
let rates = [];
|
|
566
|
+
for(let j = 0; j < rateTestJobs; j++) {
|
|
567
|
+
const start = new Date().getTime();
|
|
568
|
+
await UploadJob(jobs[j], j);
|
|
569
|
+
const elapsed = (new Date().getTime() - start) / 1000;
|
|
570
|
+
const size = jobSpecs[j].files.map(file => file.len).reduce((length, total) => length + total, 0);
|
|
571
|
+
rates.push(size / elapsed / (1024 * 1024));
|
|
436
572
|
}
|
|
437
|
-
};
|
|
438
573
|
|
|
439
|
-
|
|
440
|
-
PrepareJobs().catch(e => {
|
|
441
|
-
throw e;
|
|
442
|
-
});
|
|
574
|
+
const averageRate = rates.reduce((mbps, total) => mbps + total, 0) / rateTestJobs;
|
|
443
575
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
576
|
+
// Upload remaining jobs in parallel
|
|
577
|
+
const concurrentUploads = Math.min(5, Math.ceil(averageRate / 2));
|
|
578
|
+
await this.utils.LimitedMap(
|
|
579
|
+
concurrentUploads,
|
|
580
|
+
jobs,
|
|
581
|
+
async (jobId, j) => {
|
|
582
|
+
if(j < rateTestJobs) { return; }
|
|
583
|
+
try {
|
|
584
|
+
await UploadJob(jobId, j);
|
|
585
|
+
} catch(err) {
|
|
586
|
+
throw Error (`Failed upload for job ${jobId}: ${err}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
);
|
|
453
590
|
}
|
|
454
|
-
|
|
455
|
-
const averageRate = rates.reduce((mbps, total) => mbps + total, 0) / rateTestJobs;
|
|
456
|
-
|
|
457
|
-
// Upload remaining jobs in parallel
|
|
458
|
-
const concurrentUploads = Math.min(5, Math.ceil(averageRate / 2));
|
|
459
|
-
await this.utils.LimitedMap(
|
|
460
|
-
concurrentUploads,
|
|
461
|
-
jobs,
|
|
462
|
-
async (jobId, j) => {
|
|
463
|
-
if(j < rateTestJobs) { return; }
|
|
464
|
-
|
|
465
|
-
await UploadJob(jobId, j);
|
|
466
|
-
}
|
|
467
|
-
);
|
|
468
591
|
};
|
|
469
592
|
|
|
470
593
|
exports.CreateFileUploadJob = async function({libraryId, objectId, writeToken, ops, defaults={}, encryption="none"}) {
|
|
@@ -496,12 +619,84 @@ exports.CreateFileUploadJob = async function({libraryId, objectId, writeToken, o
|
|
|
496
619
|
});
|
|
497
620
|
};
|
|
498
621
|
|
|
622
|
+
exports.ListFilesJob = async function({libraryId, objectId, writeToken, encryption="none"}) {
|
|
623
|
+
ValidateParameters({libraryId, objectId});
|
|
624
|
+
ValidateWriteToken(writeToken);
|
|
625
|
+
|
|
626
|
+
this.Log(`List file jobs: ${libraryId} ${objectId} ${writeToken}`);
|
|
627
|
+
|
|
628
|
+
const path = UrlJoin( "q", writeToken, "file_jobs");
|
|
629
|
+
|
|
630
|
+
return this.HttpClient.RequestJsonBody({
|
|
631
|
+
headers: await this.authClient.AuthorizationHeader({libraryId, objectId, update: true, encryption}),
|
|
632
|
+
method: "GET",
|
|
633
|
+
path: path,
|
|
634
|
+
allowFailover: false
|
|
635
|
+
});
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
exports.ListFilesUploadJobs = async function({libraryId, objectId, writeToken, jobId, encryption="none"}) {
|
|
639
|
+
ValidateParameters({libraryId, objectId});
|
|
640
|
+
ValidateWriteToken(writeToken);
|
|
641
|
+
|
|
642
|
+
this.Log(`List file upload jobs: ${libraryId} ${objectId} ${writeToken}`);
|
|
643
|
+
|
|
644
|
+
const path = UrlJoin("qlibs", libraryId, "q", writeToken, "file_jobs", jobId, "uploads");
|
|
645
|
+
|
|
646
|
+
return this.HttpClient.RequestJsonBody({
|
|
647
|
+
headers: await this.authClient.AuthorizationHeader({libraryId, objectId, update: true, encryption}),
|
|
648
|
+
method: "GET",
|
|
649
|
+
path: path,
|
|
650
|
+
allowFailover: false
|
|
651
|
+
});
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
exports.ResumeFileUploadJob = async function({libraryId, objectId, writeToken, ops, defaults={}, encryption="none", stoppedOrFailedJobIds=[]}) {
|
|
655
|
+
ValidateParameters({libraryId, objectId});
|
|
656
|
+
ValidateWriteToken(writeToken);
|
|
657
|
+
|
|
658
|
+
if(stoppedOrFailedJobIds.length === 0) {
|
|
659
|
+
this.Log("No stopped/failed jobs found");
|
|
660
|
+
throw new Error("No STOPPED/FAILED jobs found");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
this.Log(`Resuming file upload job: ${libraryId} ${objectId} ${writeToken}`);
|
|
664
|
+
|
|
665
|
+
if(encryption === "cgck") {
|
|
666
|
+
defaults.encryption = { scheme: "cgck" };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const body = {
|
|
670
|
+
seq: 0,
|
|
671
|
+
seq_complete: true,
|
|
672
|
+
clear_resolve: true,
|
|
673
|
+
defaults,
|
|
674
|
+
ops
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// resume and change jobId status from 'STOPPED' to 'IN_PROGRESS'
|
|
678
|
+
const responses = [];
|
|
679
|
+
this.Log(`Stopped or Failed job IDs: ${stoppedOrFailedJobIds.join(",")}`);
|
|
680
|
+
for(const jobId of stoppedOrFailedJobIds) {
|
|
681
|
+
const path = UrlJoin("q", writeToken, "file_jobs" , jobId, "resume");
|
|
682
|
+
|
|
683
|
+
let res = await this.HttpClient.RequestJsonBody({
|
|
684
|
+
headers: await this.authClient.AuthorizationHeader({libraryId, objectId, update: true, encryption}),
|
|
685
|
+
method: "PUT",
|
|
686
|
+
path: path,
|
|
687
|
+
body,
|
|
688
|
+
allowFailover: false
|
|
689
|
+
});
|
|
690
|
+
responses.push({message: res, id: jobId});
|
|
691
|
+
}
|
|
692
|
+
return responses;
|
|
693
|
+
};
|
|
694
|
+
|
|
499
695
|
exports.UploadStatus = async function({libraryId, objectId, writeToken, uploadId}) {
|
|
500
696
|
ValidateParameters({libraryId, objectId});
|
|
501
697
|
ValidateWriteToken(writeToken);
|
|
502
698
|
|
|
503
699
|
const path = UrlJoin("q", writeToken, "file_jobs", uploadId);
|
|
504
|
-
|
|
505
700
|
return this.utils.ResponseToJson(
|
|
506
701
|
this.HttpClient.Request({
|
|
507
702
|
headers: await this.authClient.AuthorizationHeader({libraryId, objectId, update: true}),
|
|
@@ -570,7 +765,7 @@ exports.UploadFileData = async function({libraryId, objectId, writeToken, encryp
|
|
|
570
765
|
|
|
571
766
|
let path = UrlJoin("q", writeToken, "file_jobs", uploadId, jobId);
|
|
572
767
|
|
|
573
|
-
|
|
768
|
+
const response = await this.utils.ResponseToJson(
|
|
574
769
|
this.HttpClient.Request({
|
|
575
770
|
method: "POST",
|
|
576
771
|
path: path,
|
|
@@ -584,6 +779,17 @@ exports.UploadFileData = async function({libraryId, objectId, writeToken, encryp
|
|
|
584
779
|
allowRetry: false
|
|
585
780
|
})
|
|
586
781
|
);
|
|
782
|
+
|
|
783
|
+
if(response && response.part && !response.part.hash) {
|
|
784
|
+
// The request succeeded but the server received fewer bytes than the part needed to seal
|
|
785
|
+
// (e.g. a dropped/truncated connection) - part.hash is only populated once finalized, so an
|
|
786
|
+
// empty hash here means this chunk isn't actually done despite the 200. Throw so the caller's
|
|
787
|
+
// retry loop re-reads the job status (updated skip/rem) and resends the missing remainder,
|
|
788
|
+
// instead of the chunk silently being counted as complete.
|
|
789
|
+
throw new Error(`Upload did not finalize part for ${filePath} (job ${jobId})`);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return response;
|
|
587
793
|
};
|
|
588
794
|
|
|
589
795
|
exports.FinalizeUploadJob = async function({libraryId, objectId, writeToken}) {
|