@lossless.org/client 2.0.0 → 2.2.0

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.
@@ -15,7 +15,10 @@ import {
15
15
  mintExactUploadCapability,
16
16
  type IExactUploadOperation,
17
17
  } from './internal.exactupload.capability.js';
18
- import { planMultipartUpload } from './internal.multipart.planner.js';
18
+ import {
19
+ planBoundedMultipartUpload,
20
+ planMultipartUpload,
21
+ } from './internal.multipart.planner.js';
19
22
  import {
20
23
  consumeExactMultipartStream,
21
24
  MultipartStreamConsumptionError,
@@ -1306,6 +1309,69 @@ const abortMultipartUpload = async (
1306
1309
  };
1307
1310
  };
1308
1311
 
1312
+ /**
1313
+ * Plans the upload from its declared length, or from the maximum of a source
1314
+ * of undeclared length, and names that length for failure states.
1315
+ */
1316
+ const planExactUpload = (optionsArg: IFastPutStreamExactOptions) => {
1317
+ const declaresLength = optionsArg.contentLength !== undefined;
1318
+ if (declaresLength === (optionsArg.maxContentLength !== undefined)) {
1319
+ throw new TypeError(
1320
+ 'Exactly one of contentLength and maxContentLength must be given.',
1321
+ );
1322
+ }
1323
+ if (declaresLength) {
1324
+ return {
1325
+ plan: planMultipartUpload({
1326
+ objectSize: optionsArg.contentLength!,
1327
+ requestedPartSize: optionsArg.requestedPartSize,
1328
+ requestedConcurrency: optionsArg.requestedConcurrency,
1329
+ }),
1330
+ lengthState: {
1331
+ expectedContentLength: optionsArg.contentLength,
1332
+ },
1333
+ };
1334
+ }
1335
+ return {
1336
+ plan: planBoundedMultipartUpload({
1337
+ maxObjectSize: optionsArg.maxContentLength!,
1338
+ requestedPartSize: optionsArg.requestedPartSize,
1339
+ requestedConcurrency: optionsArg.requestedConcurrency,
1340
+ }),
1341
+ lengthState: {
1342
+ maxContentLength: optionsArg.maxContentLength,
1343
+ },
1344
+ };
1345
+ };
1346
+
1347
+ const sendConditionalZeroPut = (
1348
+ operationArg: IExactUploadOperation,
1349
+ optionsArg: IFastPutStreamExactOptions,
1350
+ contentTypeArg: string | undefined,
1351
+ onAttemptArg: () => void,
1352
+ ) => {
1353
+ return sendOperationCommand(
1354
+ operationArg,
1355
+ (signalArg) => {
1356
+ onAttemptArg();
1357
+ return operationArg.client.send(
1358
+ new plugins.s3.PutObjectCommand({
1359
+ Bucket: operationArg.bucketName,
1360
+ Key: optionsArg.path,
1361
+ Body: new Uint8Array(0),
1362
+ ContentLength: 0,
1363
+ Metadata: optionsArg.nativeMetadata,
1364
+ ContentType: contentTypeArg,
1365
+ IfNoneMatch: '*',
1366
+ }),
1367
+ {
1368
+ abortSignal: signalArg,
1369
+ },
1370
+ );
1371
+ },
1372
+ );
1373
+ };
1374
+
1309
1375
  export const fastPutStreamExact = async (
1310
1376
  bucketRefArg: IExactUploadBucketRef,
1311
1377
  optionsArg: IFastPutStreamExactOptions,
@@ -1316,11 +1382,7 @@ export const fastPutStreamExact = async (
1316
1382
  const nativeObjectProperties = snapshotNativeObjectProperties(
1317
1383
  optionsArg.nativeObjectProperties,
1318
1384
  );
1319
- const plan = planMultipartUpload({
1320
- objectSize: optionsArg.contentLength,
1321
- requestedPartSize: optionsArg.requestedPartSize,
1322
- requestedConcurrency: optionsArg.requestedConcurrency,
1323
- });
1385
+ const { plan, lengthState } = planExactUpload(optionsArg);
1324
1386
 
1325
1387
  let operation: IExactUploadOperation;
1326
1388
  try {
@@ -1334,7 +1396,7 @@ export const fastPutStreamExact = async (
1334
1396
  {
1335
1397
  phase: 'capability',
1336
1398
  destinationPath: optionsArg.path,
1337
- expectedContentLength: optionsArg.contentLength,
1399
+ ...lengthState,
1338
1400
  destinationPublication: 'not-published',
1339
1401
  multipartCleanup: {
1340
1402
  status: 'not-required',
@@ -1353,7 +1415,7 @@ export const fastPutStreamExact = async (
1353
1415
  {
1354
1416
  phase: 'source',
1355
1417
  destinationPath: optionsArg.path,
1356
- expectedContentLength: optionsArg.contentLength,
1418
+ ...lengthState,
1357
1419
  destinationPublication: 'not-published',
1358
1420
  multipartCleanup: {
1359
1421
  status: 'not-required',
@@ -1393,7 +1455,7 @@ export const fastPutStreamExact = async (
1393
1455
  {
1394
1456
  phase: 'source',
1395
1457
  destinationPath: optionsArg.path,
1396
- expectedContentLength: optionsArg.contentLength,
1458
+ ...lengthState,
1397
1459
  destinationPublication: 'not-published',
1398
1460
  multipartCleanup: {
1399
1461
  status: 'not-required',
@@ -1404,24 +1466,12 @@ export const fastPutStreamExact = async (
1404
1466
  }
1405
1467
 
1406
1468
  try {
1407
- const putOutput = await sendOperationCommand(
1469
+ const putOutput = await sendConditionalZeroPut(
1408
1470
  operation,
1409
- (signalArg) => {
1471
+ optionsArg,
1472
+ nativeObjectProperties?.contentType,
1473
+ () => {
1410
1474
  putRequestAttempted = true;
1411
- return operation.client.send(
1412
- new plugins.s3.PutObjectCommand({
1413
- Bucket: operation.bucketName,
1414
- Key: optionsArg.path,
1415
- Body: new Uint8Array(0),
1416
- ContentLength: 0,
1417
- Metadata: optionsArg.nativeMetadata,
1418
- ContentType: nativeObjectProperties?.contentType,
1419
- IfNoneMatch: '*',
1420
- }),
1421
- {
1422
- abortSignal: signalArg,
1423
- },
1424
- );
1425
1475
  },
1426
1476
  );
1427
1477
  return {
@@ -1441,7 +1491,7 @@ export const fastPutStreamExact = async (
1441
1491
  {
1442
1492
  phase: 'put-zero',
1443
1493
  destinationPath: optionsArg.path,
1444
- expectedContentLength: optionsArg.contentLength,
1494
+ ...lengthState,
1445
1495
  destinationPublication:
1446
1496
  putRequestAttempted &&
1447
1497
  !isDefinitiveConditionalWriteRejection(error)
@@ -1465,36 +1515,64 @@ export const fastPutStreamExact = async (
1465
1515
  }
1466
1516
  }
1467
1517
 
1468
- let phase: TExactUploadFailurePhase = 'create-multipart';
1518
+ const bounded = plan.classification === 'bounded';
1519
+ let phase: TExactUploadFailurePhase = bounded ? 'source' : 'create-multipart';
1469
1520
  let uploadId: string | undefined;
1470
1521
  let createRequestAttempted = false;
1522
+ let createFailed = false;
1523
+ let createSettled = false;
1524
+ let zeroPutAttempted = false;
1471
1525
  let completeRequestAttempted = false;
1472
1526
  let operationFinishSettlement: Promise<void> | undefined;
1473
1527
  let detachedStreamError: MultipartStreamConsumptionError | undefined;
1474
- try {
1475
- const createOutput = await sendOperationCommand(
1528
+ let multipartUploadCreation: Promise<string> | undefined;
1529
+ // A source of undeclared length creates the multipart upload with its first
1530
+ // part, so an empty source never leaves one behind.
1531
+ const ensureMultipartUpload = (): Promise<string> => {
1532
+ multipartUploadCreation ??= sendOperationCommand(
1476
1533
  operation,
1477
1534
  (signalArg) => {
1478
1535
  createRequestAttempted = true;
1479
- return operation.client.send(
1480
- new plugins.s3.CreateMultipartUploadCommand({
1481
- Bucket: operation.bucketName,
1482
- Key: optionsArg.path,
1483
- Metadata: optionsArg.nativeMetadata,
1484
- ContentType: nativeObjectProperties?.contentType,
1485
- }),
1486
- {
1487
- abortSignal: signalArg,
1488
- },
1536
+ return operation.client
1537
+ .send(
1538
+ new plugins.s3.CreateMultipartUploadCommand({
1539
+ Bucket: operation.bucketName,
1540
+ Key: optionsArg.path,
1541
+ Metadata: optionsArg.nativeMetadata,
1542
+ ContentType: nativeObjectProperties?.contentType,
1543
+ }),
1544
+ {
1545
+ abortSignal: signalArg,
1546
+ },
1547
+ )
1548
+ .then((createOutputArg) => {
1549
+ // A create that ignored cancellation still owns its upload, so
1550
+ // deferred cleanup aborts it by this id.
1551
+ uploadId = getOptionalNonEmptyString(createOutputArg.UploadId);
1552
+ return createOutputArg;
1553
+ });
1554
+ },
1555
+ ).then(
1556
+ (createOutputArg) => {
1557
+ createSettled = true;
1558
+ return requireNonEmptyString(
1559
+ createOutputArg.UploadId,
1560
+ 'CreateMultipartUpload UploadId',
1489
1561
  );
1490
1562
  },
1563
+ (error: unknown) => {
1564
+ createSettled = true;
1565
+ createFailed = true;
1566
+ throw error;
1567
+ },
1491
1568
  );
1492
- uploadId = requireNonEmptyString(
1493
- createOutput.UploadId,
1494
- 'CreateMultipartUpload UploadId',
1495
- );
1496
-
1497
- phase = 'source';
1569
+ return multipartUploadCreation;
1570
+ };
1571
+ try {
1572
+ if (!bounded) {
1573
+ await ensureMultipartUpload();
1574
+ phase = 'source';
1575
+ }
1498
1576
  const completedParts: plugins.s3.CompletedPart[] = [];
1499
1577
  let consumptionResult;
1500
1578
  try {
@@ -1505,12 +1583,14 @@ export const fastPutStreamExact = async (
1505
1583
  failureSettlementTimeoutMs:
1506
1584
  optionsArg.failureSettlementTimeoutMs,
1507
1585
  async onPart(partArg) {
1586
+ operation.assertCurrent();
1587
+ const partUploadId = await ensureMultipartUpload();
1508
1588
  operation.assertCurrent();
1509
1589
  const uploadPartOutput = await operation.client.send(
1510
1590
  new plugins.s3.UploadPartCommand({
1511
1591
  Bucket: operation.bucketName,
1512
1592
  Key: optionsArg.path,
1513
- UploadId: uploadId!,
1593
+ UploadId: partUploadId,
1514
1594
  PartNumber: partArg.partNumber,
1515
1595
  Body: partArg.data,
1516
1596
  ContentLength: partArg.data.byteLength,
@@ -1532,9 +1612,15 @@ export const fastPutStreamExact = async (
1532
1612
  } catch (error) {
1533
1613
  if (
1534
1614
  error instanceof MultipartStreamConsumptionError &&
1535
- error.state.kind === 'part-callback'
1615
+ (error.state.kind === 'part-callback' || error.state.kind === 'aborted')
1536
1616
  ) {
1537
- phase = 'upload-part';
1617
+ // A source of undeclared length creates its upload with the first
1618
+ // part, so a failed or still outstanding create owns the failure.
1619
+ if (createFailed || (multipartUploadCreation && !createSettled)) {
1620
+ phase = 'create-multipart';
1621
+ } else if (error.state.kind === 'part-callback') {
1622
+ phase = 'upload-part';
1623
+ }
1538
1624
  }
1539
1625
  if (
1540
1626
  error instanceof MultipartStreamConsumptionError &&
@@ -1548,6 +1634,28 @@ export const fastPutStreamExact = async (
1548
1634
  throw error;
1549
1635
  }
1550
1636
 
1637
+ if (!multipartUploadCreation) {
1638
+ // Only an empty source of undeclared length reaches this point.
1639
+ phase = 'put-zero';
1640
+ const putOutput = await sendConditionalZeroPut(
1641
+ operation,
1642
+ optionsArg,
1643
+ nativeObjectProperties?.contentType,
1644
+ () => {
1645
+ zeroPutAttempted = true;
1646
+ },
1647
+ );
1648
+ return {
1649
+ destinationPath: optionsArg.path,
1650
+ contentLength: 0,
1651
+ etag: requireNonEmptyString(putOutput.ETag, 'PutObject ETag'),
1652
+ versionId: getOptionalNonEmptyString(putOutput.VersionId),
1653
+ partCount: 0,
1654
+ peakBufferedBytes: consumptionResult.peakBufferedBytes,
1655
+ maximumBufferedBytes: consumptionResult.maximumBufferedBytes,
1656
+ };
1657
+ }
1658
+ const completeUploadId = await multipartUploadCreation;
1551
1659
  phase = 'complete-multipart';
1552
1660
  const completeOutput = await sendOperationCommand(
1553
1661
  operation,
@@ -1557,12 +1665,12 @@ export const fastPutStreamExact = async (
1557
1665
  new plugins.s3.CompleteMultipartUploadCommand({
1558
1666
  Bucket: operation.bucketName,
1559
1667
  Key: optionsArg.path,
1560
- UploadId: uploadId,
1668
+ UploadId: completeUploadId,
1561
1669
  MultipartUpload: {
1562
1670
  Parts: completedParts,
1563
1671
  },
1564
1672
  IfNoneMatch: '*',
1565
- MpuObjectSize: optionsArg.contentLength,
1673
+ MpuObjectSize: consumptionResult.bytesConsumed,
1566
1674
  }),
1567
1675
  {
1568
1676
  abortSignal: signalArg,
@@ -1572,7 +1680,7 @@ export const fastPutStreamExact = async (
1572
1680
  );
1573
1681
  return {
1574
1682
  destinationPath: optionsArg.path,
1575
- contentLength: optionsArg.contentLength,
1683
+ contentLength: consumptionResult.bytesConsumed,
1576
1684
  etag: requireNonEmptyString(
1577
1685
  completeOutput.ETag,
1578
1686
  'CompleteMultipartUpload ETag',
@@ -1586,21 +1694,21 @@ export const fastPutStreamExact = async (
1586
1694
  let multipartCleanup: TExactUploadMultipartCleanup;
1587
1695
  const primaryProviderSettlement =
1588
1696
  getDetachedOperationSendSettlement(operation.signal);
1589
- if (
1590
- uploadId &&
1591
- detachedStreamError?.state.cleanup.callbacks === 'detached'
1592
- ) {
1593
- multipartCleanup = {
1594
- status: 'deferred',
1595
- };
1596
- const callbackGatedCleanup = detachedStreamError.callbackSettlement
1697
+ // Aborts the multipart upload once the given work settles. A create that
1698
+ // was still in flight may only report its UploadId by then.
1699
+ const abortAfterSettlement = (settlementArg: Promise<unknown>) =>
1700
+ settlementArg
1597
1701
  .then(async () => {
1702
+ await getDetachedOperationSendSettlement(operation.signal);
1703
+ if (!uploadId) {
1704
+ return;
1705
+ }
1598
1706
  try {
1599
1707
  operation.assertLifecycleCurrent();
1600
1708
  const abortResult = await abortMultipartUpload(
1601
1709
  operation,
1602
1710
  optionsArg.path,
1603
- uploadId!,
1711
+ uploadId,
1604
1712
  );
1605
1713
  await abortResult.settlement;
1606
1714
  } catch {
@@ -1611,35 +1719,27 @@ export const fastPutStreamExact = async (
1611
1719
  () => undefined,
1612
1720
  () => undefined,
1613
1721
  );
1722
+ if (
1723
+ (uploadId || createRequestAttempted) &&
1724
+ detachedStreamError?.state.cleanup.callbacks === 'detached'
1725
+ ) {
1726
+ multipartCleanup = {
1727
+ status: 'deferred',
1728
+ };
1614
1729
  operationFinishSettlement = Promise.allSettled([
1615
- callbackGatedCleanup,
1730
+ abortAfterSettlement(detachedStreamError.callbackSettlement),
1616
1731
  detachedStreamError.ownedSettlement,
1617
1732
  ]).then(() => undefined);
1618
- } else if (uploadId && primaryProviderSettlement) {
1733
+ } else if (
1734
+ (uploadId || createRequestAttempted) &&
1735
+ primaryProviderSettlement
1736
+ ) {
1619
1737
  multipartCleanup = {
1620
1738
  status: 'deferred',
1621
1739
  };
1622
- const providerGatedCleanup = primaryProviderSettlement
1623
- .then(async () => {
1624
- try {
1625
- operation.assertLifecycleCurrent();
1626
- const abortResult = await abortMultipartUpload(
1627
- operation,
1628
- optionsArg.path,
1629
- uploadId!,
1630
- );
1631
- await abortResult.settlement;
1632
- } catch {
1633
- // Lifecycle close makes deferred cleanup indeterminate.
1634
- }
1635
- })
1636
- .then(
1637
- () => undefined,
1638
- () => undefined,
1639
- );
1640
1740
  operationFinishSettlement = Promise.allSettled([
1641
1741
  primaryProviderSettlement,
1642
- providerGatedCleanup,
1742
+ abortAfterSettlement(primaryProviderSettlement),
1643
1743
  ]).then(() => undefined);
1644
1744
  } else if (uploadId) {
1645
1745
  const abortResult = await abortMultipartUpload(
@@ -1653,20 +1753,11 @@ export const fastPutStreamExact = async (
1653
1753
  }
1654
1754
  } else if (createRequestAttempted) {
1655
1755
  multipartCleanup = {
1656
- ...(primaryProviderSettlement
1657
- ? {
1658
- status: 'deferred' as const,
1659
- }
1660
- : {
1661
- status: 'abort-failed' as const,
1662
- cause: new Error(
1663
- 'The multipart upload could not be aborted because no UploadId was returned.',
1664
- ),
1665
- }),
1756
+ status: 'abort-failed',
1757
+ cause: new Error(
1758
+ 'The multipart upload could not be aborted because no UploadId was returned.',
1759
+ ),
1666
1760
  };
1667
- if (primaryProviderSettlement) {
1668
- operationFinishSettlement = primaryProviderSettlement;
1669
- }
1670
1761
  } else {
1671
1762
  multipartCleanup = {
1672
1763
  status: 'not-required',
@@ -1691,10 +1782,12 @@ export const fastPutStreamExact = async (
1691
1782
  {
1692
1783
  phase,
1693
1784
  destinationPath: optionsArg.path,
1694
- expectedContentLength: optionsArg.contentLength,
1785
+ ...lengthState,
1695
1786
  destinationPublication:
1696
- phase === 'complete-multipart' &&
1697
- completeRequestAttempted &&
1787
+ (
1788
+ (phase === 'complete-multipart' && completeRequestAttempted) ||
1789
+ (phase === 'put-zero' && zeroPutAttempted)
1790
+ ) &&
1698
1791
  !isDefinitiveConditionalWriteRejection(error)
1699
1792
  ? 'ambiguous'
1700
1793
  : 'not-published',
@@ -1,6 +1,8 @@
1
1
  import type {
2
+ IMultipartBoundedPlanningOptions,
3
+ IMultipartBoundedUploadPlan,
2
4
  IMultipartPlanningOptions,
3
- TMultipartUploadPlan,
5
+ TMultipartDeclaredUploadPlan,
4
6
  } from './internal.multipart.types.js';
5
7
 
6
8
  export const MULTIPART_MIN_PART_SIZE = 5 * 1024 * 1024;
@@ -43,7 +45,7 @@ const normalizeRequestedConcurrency = (
43
45
 
44
46
  export const planMultipartUpload = (
45
47
  optionsArg: IMultipartPlanningOptions,
46
- ): TMultipartUploadPlan => {
48
+ ): TMultipartDeclaredUploadPlan => {
47
49
  requireSafeNonNegativeInteger(optionsArg.objectSize, 'objectSize');
48
50
  if (optionsArg.objectSize > MULTIPART_MAX_OBJECT_SIZE) {
49
51
  throw new RangeError(
@@ -88,3 +90,45 @@ export const planMultipartUpload = (
88
90
  ) + Math.min(optionsArg.objectSize, partSize),
89
91
  };
90
92
  };
93
+
94
+ /**
95
+ * Plans an upload of undeclared length that must not exceed
96
+ * `maxObjectSize`. The part size grows with the maximum so that the largest
97
+ * allowed source still fits into the multipart part limit.
98
+ */
99
+ export const planBoundedMultipartUpload = (
100
+ optionsArg: IMultipartBoundedPlanningOptions,
101
+ ): IMultipartBoundedUploadPlan => {
102
+ if (!Number.isSafeInteger(optionsArg.maxObjectSize) || optionsArg.maxObjectSize < 1) {
103
+ throw new TypeError('maxObjectSize must be a positive safe integer.');
104
+ }
105
+ if (optionsArg.maxObjectSize > MULTIPART_MAX_OBJECT_SIZE) {
106
+ throw new RangeError(
107
+ `maxObjectSize exceeds the ${MULTIPART_MAX_OBJECT_SIZE} byte multipart limit.`,
108
+ );
109
+ }
110
+ const requestedConcurrency = normalizeRequestedConcurrency(
111
+ optionsArg.requestedConcurrency,
112
+ );
113
+ const preferredPartSize = normalizeRequestedPartSize(optionsArg.requestedPartSize);
114
+ const partSize = Math.max(
115
+ preferredPartSize,
116
+ Math.ceil(optionsArg.maxObjectSize / MULTIPART_MAX_PART_COUNT),
117
+ );
118
+ if (partSize > MULTIPART_MAX_PART_SIZE) {
119
+ throw new RangeError('The maximum cannot be represented by legal multipart parts.');
120
+ }
121
+ const maxPartCount = Math.ceil(optionsArg.maxObjectSize / partSize);
122
+ const concurrency = Math.min(requestedConcurrency, maxPartCount);
123
+ return {
124
+ classification: 'bounded',
125
+ maxObjectSize: optionsArg.maxObjectSize,
126
+ partSize,
127
+ maxPartCount,
128
+ concurrency,
129
+ maximumBufferedBytes: Math.min(
130
+ optionsArg.maxObjectSize,
131
+ partSize * concurrency,
132
+ ) + Math.min(optionsArg.maxObjectSize, partSize),
133
+ };
134
+ };