@eluvio/elv-client-js 4.2.27 → 4.2.30
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/dist/ElvClient-min.js +1 -1
- package/dist/ElvClient-node-min.js +1 -1
- package/dist/ElvFrameClient-min.js +1 -1
- package/dist/ElvPermissionsClient-min.js +1 -1
- package/dist/ElvWalletClient-min.js +1 -1
- package/dist/ElvWalletClient-node-min.js +1 -1
- package/dist/src/ElvClient.js +355 -275
- package/dist/src/EthClient.js +3 -2
- package/dist/src/FrameClient.js +1 -1
- package/dist/src/client/ABRPublishing.js +71 -60
- package/dist/src/client/ContentAccess.js +298 -204
- package/dist/src/client/Files.js +1400 -844
- package/dist/src/client/LiveConf.js +54 -37
- package/dist/src/client/LiveStream.js +123 -81
- package/package.json +1 -1
- package/src/ElvClient.js +51 -0
- package/src/EthClient.js +2 -1
- package/src/FrameClient.js +1 -0
- package/src/client/ContentAccess.js +70 -1
- package/src/client/Files.js +413 -208
- package/src/client/LiveStream.js +32 -16
- package/utilities/MigrateEncryptionConkForNewUser.js +62 -0
- package/utilities/ObjectAddFiles.js +29 -9
- package/utilities/lib/concerns/CloudFile.js +2 -1
- package/utilities/lib/concerns/LocalFile.js +2 -1
- package/dist/src/ContentObjectVerification.js +0 -281
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
|
|
@@ -82,6 +90,7 @@ exports.ListFiles = async function({libraryId, objectId, path = "", versionHash,
|
|
|
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,61 +105,41 @@ 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
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
if(signedUrl) {
|
|
150
|
-
cloudCredentials = {
|
|
151
|
-
signed_url: signedUrl
|
|
152
|
-
};
|
|
153
|
-
}
|
|
140
|
+
const cloudCredentials = signedUrl
|
|
141
|
+
? { signed_url: signedUrl }
|
|
142
|
+
: { access_key_id: accessKey, secret_access_key: secret };
|
|
154
143
|
|
|
155
144
|
const remoteAccess = {
|
|
156
145
|
protocol: "s3",
|
|
@@ -191,55 +180,94 @@ exports.UploadFilesFromS3 = async function({
|
|
|
191
180
|
}
|
|
192
181
|
});
|
|
193
182
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
}
|
|
200
197
|
|
|
201
|
-
|
|
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
|
+
}
|
|
202
225
|
|
|
203
|
-
|
|
204
|
-
throw status.errors.join("\n");
|
|
205
|
-
} else if(status.error) {
|
|
206
|
-
this.Log(`S3 file upload failed:\n${JSON.stringify(status, null, 2)}`);
|
|
207
|
-
throw status.error;
|
|
208
|
-
} else if(status.status.toLowerCase() === "failed") {
|
|
209
|
-
throw "File upload failed";
|
|
226
|
+
if(done) break;
|
|
210
227
|
}
|
|
228
|
+
};
|
|
211
229
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
callback({
|
|
220
|
-
done,
|
|
221
|
-
uploaded: progress.bytes.completed,
|
|
222
|
-
total: progress.bytes.total,
|
|
223
|
-
uploadedFiles: progress.files.completed,
|
|
224
|
-
totalFiles: progress.files.total,
|
|
225
|
-
fileStatus: progress.files.details
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
} else {
|
|
229
|
-
done = status.add_reference.done;
|
|
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
|
+
}, {});
|
|
230
237
|
|
|
231
|
-
|
|
232
|
-
|
|
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
|
+
};
|
|
233
253
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
totalFiles: progress.total,
|
|
238
|
-
});
|
|
254
|
+
for(const status of [JobStatus.STOPPED, JobStatus.FAILED]) {
|
|
255
|
+
if(jobsByStatus[status]){
|
|
256
|
+
await resumeAndTrack([...jobsByStatus[status].keys()]);
|
|
239
257
|
}
|
|
240
258
|
}
|
|
241
259
|
|
|
242
|
-
|
|
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 });
|
|
243
271
|
}
|
|
244
272
|
};
|
|
245
273
|
|
|
@@ -248,14 +276,14 @@ exports.UploadFilesFromS3 = async function({
|
|
|
248
276
|
*
|
|
249
277
|
* Expected format of fileInfo:
|
|
250
278
|
*
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
279
|
+
[
|
|
280
|
+
{
|
|
281
|
+
path: string,
|
|
282
|
+
mime_type: string,
|
|
283
|
+
size: number,
|
|
284
|
+
data: File | ArrayBuffer | Buffer | File Descriptor (Node)
|
|
285
|
+
}
|
|
286
|
+
]
|
|
259
287
|
*
|
|
260
288
|
*
|
|
261
289
|
* @memberof module:ElvClient/Files+Parts
|
|
@@ -265,11 +293,12 @@ exports.UploadFilesFromS3 = async function({
|
|
|
265
293
|
* @param {string} objectId - ID of the object
|
|
266
294
|
* @param {string} writeToken - Write token of the draft
|
|
267
295
|
* @param {Array<object>} fileInfo - List of files to upload, including their size, type, and contents
|
|
296
|
+
* @param {string} resume - Resume the upload jobs
|
|
268
297
|
* @param {string} encryption="none" - Encryption for uploaded files - cgck | none
|
|
269
298
|
* @param {function=} callback - If specified, will be called after each job segment is finished with the current upload progress
|
|
270
299
|
* - Format: {"filename1": {uploaded: number, total: number}, ...}
|
|
271
300
|
*/
|
|
272
|
-
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}) {
|
|
273
302
|
ValidateParameters({libraryId, objectId});
|
|
274
303
|
ValidateWriteToken(writeToken);
|
|
275
304
|
ValidatePresence("fileInfo", fileInfo);
|
|
@@ -312,160 +341,253 @@ exports.UploadFiles = async function({libraryId, objectId, writeToken, fileInfo,
|
|
|
312
341
|
|
|
313
342
|
this.Log(fileInfo);
|
|
314
343
|
|
|
315
|
-
|
|
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) {
|
|
316
348
|
callback(progress);
|
|
317
349
|
}
|
|
318
350
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
objectId,
|
|
322
|
-
writeToken,
|
|
323
|
-
ops: fileInfo,
|
|
324
|
-
encryption
|
|
325
|
-
});
|
|
351
|
+
let idJobMap = new Map();
|
|
352
|
+
if(resume) {
|
|
353
|
+
const ids = await this.ListFilesJob({ libraryId, objectId, writeToken, encryption });
|
|
326
354
|
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
}, {});
|
|
329
360
|
|
|
330
|
-
|
|
331
|
-
|
|
361
|
+
const stoppedJobIds = jobsByStatus.STOPPED || [];
|
|
362
|
+
const failedJobIds = jobsByStatus.FAILED || [];
|
|
363
|
+
const inProgressJobIds = jobsByStatus.IN_PROGRESS || [];
|
|
332
364
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
365
|
+
const resumeJobIds = [...stoppedJobIds, ...failedJobIds, ...inProgressJobIds];
|
|
366
|
+
if(resumeJobIds.length === 0) {
|
|
367
|
+
this.Log("No job Ids to resume");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
336
370
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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);
|
|
343
386
|
}
|
|
387
|
+
}
|
|
344
388
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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({
|
|
348
392
|
libraryId,
|
|
349
393
|
objectId,
|
|
350
394
|
writeToken,
|
|
351
|
-
|
|
352
|
-
|
|
395
|
+
jobId: uploadId,
|
|
396
|
+
encryption
|
|
353
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
|
+
}
|
|
354
412
|
|
|
355
|
-
|
|
356
|
-
|
|
413
|
+
// How far encryption can get ahead of upload (500 MB)
|
|
414
|
+
const bufferSize = 500 * 1024 * 1024;
|
|
357
415
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
+
}
|
|
366
450
|
}
|
|
451
|
+
});
|
|
367
452
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
453
|
+
callback(progress);
|
|
454
|
+
}
|
|
371
455
|
|
|
372
|
-
|
|
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
|
+
}
|
|
373
463
|
|
|
374
|
-
|
|
375
|
-
|
|
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
|
+
});
|
|
376
473
|
|
|
377
|
-
|
|
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
|
+
}
|
|
378
486
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
};
|
|
487
|
+
if(encryption === "cgck") {
|
|
488
|
+
data = await this.Crypto.Encrypt(conk, data);
|
|
489
|
+
}
|
|
383
490
|
|
|
384
|
-
|
|
385
|
-
while(!jobSpecs[j]) {
|
|
386
|
-
// Wait for more jobs to be prepared
|
|
387
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
388
|
-
}
|
|
491
|
+
job.files[f].data = data;
|
|
389
492
|
|
|
390
|
-
|
|
391
|
-
|
|
493
|
+
prepared += fileInfo.len;
|
|
494
|
+
}
|
|
392
495
|
|
|
393
|
-
|
|
394
|
-
for(let f = 0; f < files.length; f++) {
|
|
395
|
-
const fileInfo = files[f];
|
|
496
|
+
jobSpecs[j] = job;
|
|
396
497
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
try {
|
|
401
|
-
await this.UploadFileData({
|
|
402
|
-
libraryId,
|
|
403
|
-
objectId,
|
|
404
|
-
writeToken,
|
|
405
|
-
uploadId: id,
|
|
406
|
-
jobId,
|
|
407
|
-
filePath: fileInfo.path,
|
|
408
|
-
fileData: fileInfo.data,
|
|
409
|
-
encryption
|
|
410
|
-
});
|
|
498
|
+
// Wait for a bit to let upload start
|
|
499
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
500
|
+
}
|
|
411
501
|
|
|
412
|
-
|
|
413
|
-
} catch(error) {
|
|
414
|
-
this.Log(error, true);
|
|
502
|
+
};
|
|
415
503
|
|
|
416
|
-
|
|
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
|
+
}
|
|
417
509
|
|
|
418
|
-
|
|
419
|
-
|
|
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));
|
|
420
543
|
}
|
|
544
|
+
} while(!succeeded && retries < 10);
|
|
421
545
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
} while(!succeeded && retries < 10);
|
|
546
|
+
delete jobSpecs[j].files[f].data;
|
|
547
|
+
uploaded += fileInfo.len;
|
|
425
548
|
|
|
426
|
-
|
|
427
|
-
|
|
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
|
+
};
|
|
428
554
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
...progress[fileInfo.path],
|
|
432
|
-
uploaded: progress[fileInfo.path].uploaded + fileInfo.len
|
|
433
|
-
};
|
|
434
|
-
|
|
435
|
-
callback(progress);
|
|
555
|
+
callback(progress);
|
|
556
|
+
}
|
|
436
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));
|
|
437
572
|
}
|
|
438
|
-
};
|
|
439
573
|
|
|
440
|
-
|
|
441
|
-
PrepareJobs().catch(e => {
|
|
442
|
-
throw e;
|
|
443
|
-
});
|
|
574
|
+
const averageRate = rates.reduce((mbps, total) => mbps + total, 0) / rateTestJobs;
|
|
444
575
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
+
);
|
|
454
590
|
}
|
|
455
|
-
|
|
456
|
-
const averageRate = rates.reduce((mbps, total) => mbps + total, 0) / rateTestJobs;
|
|
457
|
-
|
|
458
|
-
// Upload remaining jobs in parallel
|
|
459
|
-
const concurrentUploads = Math.min(5, Math.ceil(averageRate / 2));
|
|
460
|
-
await this.utils.LimitedMap(
|
|
461
|
-
concurrentUploads,
|
|
462
|
-
jobs,
|
|
463
|
-
async (jobId, j) => {
|
|
464
|
-
if(j < rateTestJobs) { return; }
|
|
465
|
-
|
|
466
|
-
await UploadJob(jobId, j);
|
|
467
|
-
}
|
|
468
|
-
);
|
|
469
591
|
};
|
|
470
592
|
|
|
471
593
|
exports.CreateFileUploadJob = async function({libraryId, objectId, writeToken, ops, defaults={}, encryption="none"}) {
|
|
@@ -497,12 +619,84 @@ exports.CreateFileUploadJob = async function({libraryId, objectId, writeToken, o
|
|
|
497
619
|
});
|
|
498
620
|
};
|
|
499
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
|
+
|
|
500
695
|
exports.UploadStatus = async function({libraryId, objectId, writeToken, uploadId}) {
|
|
501
696
|
ValidateParameters({libraryId, objectId});
|
|
502
697
|
ValidateWriteToken(writeToken);
|
|
503
698
|
|
|
504
699
|
const path = UrlJoin("q", writeToken, "file_jobs", uploadId);
|
|
505
|
-
|
|
506
700
|
return this.utils.ResponseToJson(
|
|
507
701
|
this.HttpClient.Request({
|
|
508
702
|
headers: await this.authClient.AuthorizationHeader({libraryId, objectId, update: true}),
|
|
@@ -571,7 +765,7 @@ exports.UploadFileData = async function({libraryId, objectId, writeToken, encryp
|
|
|
571
765
|
|
|
572
766
|
let path = UrlJoin("q", writeToken, "file_jobs", uploadId, jobId);
|
|
573
767
|
|
|
574
|
-
|
|
768
|
+
const response = await this.utils.ResponseToJson(
|
|
575
769
|
this.HttpClient.Request({
|
|
576
770
|
method: "POST",
|
|
577
771
|
path: path,
|
|
@@ -585,6 +779,17 @@ exports.UploadFileData = async function({libraryId, objectId, writeToken, encryp
|
|
|
585
779
|
allowRetry: false
|
|
586
780
|
})
|
|
587
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;
|
|
588
793
|
};
|
|
589
794
|
|
|
590
795
|
exports.FinalizeUploadJob = async function({libraryId, objectId, writeToken}) {
|