@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.
@@ -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
- * Copy/reference files from S3 to a content object.
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
- path: string,
67
- source: string // either a full path e.g. "s3://BUCKET_NAME/path..." or just the path part without "s3://BUCKET_NAME/"
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 = (s3prefixRegex.exec(fileSourcePath));
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
- if(copy) {
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
- libraryId,
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
- let cloudCredentials = {
145
- access_key_id: accessKey,
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(signedUrl) {
150
- cloudCredentials = {
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
- // eslint-disable-next-line no-unused-vars
194
- const {id} = await this.CreateFileUploadJob({libraryId, objectId, writeToken, ops, defaults});
195
-
196
- // eslint-disable-next-line no-constant-condition
197
- while(true) {
198
- await new Promise(resolve => setTimeout(resolve, 1000));
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
- const status = await this.UploadStatus({libraryId, objectId, writeToken, uploadId: id});
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
- if(status.errors && status.errors.length > 1) {
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
- let done = false;
212
- if(copy) {
213
- done = status.ingest_copy.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
+ }, {});
214
237
 
215
- if(callback) {
216
- const progress = status.ingest_copy.progress;
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
- callback({
219
- done,
220
- uploaded: progress.bytes.completed,
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
- if(done) { break; }
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
- path: string,
253
- mime_type: string,
254
- size: number,
255
- data: File | ArrayBuffer | Buffer | File Descriptor (Node)
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
- if(callback) {
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
- const {id, jobs} = await this.CreateFileUploadJob({
319
- libraryId,
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
- this.Log(`Upload ID: ${id}`);
327
- this.Log(jobs);
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
- // How far encryption can get ahead of upload
330
- const bufferSize = 500 * 1024 * 1024;
361
+ const stoppedJobIds = jobsByStatus.STOPPED || [];
362
+ const failedJobIds = jobsByStatus.FAILED || [];
363
+ const inProgressJobIds = jobsByStatus.IN_PROGRESS || [];
331
364
 
332
- let jobSpecs = [];
333
- let prepared = 0;
334
- let uploaded = 0;
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
- // Insert the data to upload into the job spec, encrypting if necessary
337
- const PrepareJobs = async () => {
338
- for(let j = 0; j < jobs.length; j++) {
339
- while(prepared - uploaded > bufferSize) {
340
- // Wait for more data to be uploaded
341
- await new Promise(resolve => setTimeout(resolve, 500));
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
- // Retrieve job info
345
- const jobId = jobs[j];
346
- let job = await this.UploadJobStatus({
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
- uploadId: id,
351
- jobId
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
- for(let f = 0; f < job.files.length; f++) {
355
- const fileInfo = job.files[f];
413
+ // How far encryption can get ahead of upload (500 MB)
414
+ const bufferSize = 500 * 1024 * 1024;
356
415
 
357
- let data;
358
- if(typeof fileDataMap[fileInfo.path] === "number") {
359
- // File descriptor - Read data from file
360
- data = Buffer.alloc(fileInfo.len);
361
- fs.readSync(fileDataMap[fileInfo.path], data, 0, fileInfo.len, fileInfo.off);
362
- } else {
363
- // Full data - Slice requested chunk
364
- data = fileDataMap[fileInfo.path].slice(fileInfo.off, fileInfo.off + fileInfo.len);
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
- if(encryption === "cgck") {
368
- data = await this.Crypto.Encrypt(conk, data);
369
- }
453
+ callback(progress);
454
+ }
370
455
 
371
- job.files[f].data = data;
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
- prepared += fileInfo.len;
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
- jobSpecs[j] = job;
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
- // Wait for a bit to let upload start
379
- await new Promise(resolve => setTimeout(resolve, 50));
380
- }
381
- };
487
+ if(encryption === "cgck") {
488
+ data = await this.Crypto.Encrypt(conk, data);
489
+ }
382
490
 
383
- const UploadJob = async (jobId, j) => {
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
- const jobSpec = jobSpecs[j];
390
- const files = jobSpec.files;
493
+ prepared += fileInfo.len;
494
+ }
391
495
 
392
- // Upload each item
393
- for(let f = 0; f < files.length; f++) {
394
- const fileInfo = files[f];
496
+ jobSpecs[j] = job;
395
497
 
396
- let retries = 0;
397
- let succeeded = false;
398
- do {
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
- succeeded = true;
412
- } catch(error) {
413
- this.Log(error, true);
502
+ };
414
503
 
415
- retries += 1;
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
- if(retries >= 10) {
418
- throw error;
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
- await new Promise(resolve => setTimeout(resolve, 10 * retries * 1000));
422
- }
423
- } while(!succeeded && retries < 10);
546
+ delete jobSpecs[j].files[f].data;
547
+ uploaded += fileInfo.len;
424
548
 
425
- delete jobSpecs[j].files[f].data;
426
- uploaded += fileInfo.len;
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
- if(callback) {
429
- progress[fileInfo.path] = {
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
- // Preparing jobs is done asynchronously
440
- PrepareJobs().catch(e => {
441
- throw e;
442
- });
574
+ const averageRate = rates.reduce((mbps, total) => mbps + total, 0) / rateTestJobs;
443
575
 
444
- // Upload the first several chunks in sequence, to determine average upload rate
445
- const rateTestJobs = Math.min(3, jobs.length);
446
- let rates = [];
447
- for(let j = 0; j < rateTestJobs; j++) {
448
- const start = new Date().getTime();
449
- await UploadJob(jobs[j], j);
450
- const elapsed = (new Date().getTime() - start) / 1000;
451
- const size = jobSpecs[j].files.map(file => file.len).reduce((length, total) => length + total, 0);
452
- rates.push(size / elapsed / (1024 * 1024));
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
- return await this.utils.ResponseToJson(
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}) {