Skip to content

Commit 1f1905e

Browse files
authored
fix(middleware-flexible-checksums): buffer stream chunks to minimum required size (#6882)
1 parent a1d8ad3 commit 1f1905e

File tree

9 files changed

+258
-15
lines changed

9 files changed

+258
-15
lines changed

packages/middleware-flexible-checksums/src/configuration.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,9 @@ export interface PreviouslyResolved {
6666
* Collects streams into buffers.
6767
*/
6868
streamCollector: StreamCollector;
69+
70+
/**
71+
* Minimum bytes from a stream to buffer into a chunk before passing to chunked encoding.
72+
*/
73+
requestStreamBufferSize: number;
6974
}

packages/middleware-flexible-checksums/src/flexibleChecksumsMiddleware.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
HandlerExecutionContext,
1010
MetadataBearer,
1111
} from "@smithy/types";
12+
import { createBufferedReadable } from "@smithy/util-stream";
1213

1314
import { PreviouslyResolved } from "./configuration";
1415
import { ChecksumAlgorithm, DEFAULT_CHECKSUM_ALGORITHM, RequestChecksumCalculation } from "./constants";
@@ -119,13 +120,18 @@ export const flexibleChecksumsMiddleware =
119120
const checksumAlgorithmFn = selectChecksumAlgorithmFunction(checksumAlgorithm, config);
120121
if (isStreaming(requestBody)) {
121122
const { getAwsChunkedEncodingStream, bodyLengthChecker } = config;
122-
updatedBody = getAwsChunkedEncodingStream(requestBody, {
123-
base64Encoder,
124-
bodyLengthChecker,
125-
checksumLocationName,
126-
checksumAlgorithmFn,
127-
streamHasher,
128-
});
123+
updatedBody = getAwsChunkedEncodingStream(
124+
typeof config.requestStreamBufferSize === "number" && config.requestStreamBufferSize >= 8 * 1024
125+
? createBufferedReadable(requestBody, config.requestStreamBufferSize, context.logger)
126+
: requestBody,
127+
{
128+
base64Encoder,
129+
bodyLengthChecker,
130+
checksumLocationName,
131+
checksumAlgorithmFn,
132+
streamHasher,
133+
}
134+
);
129135
updatedHeaders = {
130136
...headers,
131137
"content-encoding": headers["content-encoding"]

packages/middleware-flexible-checksums/src/middleware-flexible-checksums.e2e.spec.ts

Lines changed: 172 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,41 @@
1-
import { S3 } from "@aws-sdk/client-s3";
1+
import { S3, UploadPartCommandOutput } from "@aws-sdk/client-s3";
2+
import { Upload } from "@aws-sdk/lib-storage";
3+
import { FetchHttpHandler } from "@smithy/fetch-http-handler";
24
import type { HttpRequest, HttpResponse } from "@smithy/types";
3-
import { headStream } from "@smithy/util-stream";
5+
import { ChecksumStream, headStream } from "@smithy/util-stream";
46
import { Readable } from "node:stream";
5-
import { beforeAll, describe, expect, test as it } from "vitest";
7+
import { beforeAll, describe, expect, test as it, vi } from "vitest";
68

79
import { getIntegTestResources } from "../../../tests/e2e/get-integ-test-resources";
810

911
describe("S3 checksums", () => {
1012
let s3: S3;
1113
let s3_noChecksum: S3;
14+
let s3_noRequestBuffer: S3;
1215
let Bucket: string;
1316
let Key: string;
1417
let region: string;
1518
const expected = new Uint8Array([97, 98, 99, 100]);
19+
const logger = {
20+
debug: vi.fn(),
21+
info: vi.fn(),
22+
warn: vi.fn(),
23+
error: vi.fn(),
24+
};
25+
26+
function stream(size: number, chunkSize: number) {
27+
async function* generate() {
28+
while (size > 0) {
29+
const z = Math.min(size, chunkSize);
30+
yield "a".repeat(z);
31+
size -= z;
32+
}
33+
}
34+
return Readable.from(generate());
35+
}
36+
function webStream(size: number, chunkSize: number) {
37+
return Readable.toWeb(stream(size, chunkSize)) as unknown as ReadableStream;
38+
}
1639

1740
beforeAll(async () => {
1841
const integTestResourcesEnv = await getIntegTestResources();
@@ -21,7 +44,8 @@ describe("S3 checksums", () => {
2144
region = process?.env?.AWS_SMOKE_TEST_REGION as string;
2245
Bucket = process?.env?.AWS_SMOKE_TEST_BUCKET as string;
2346

24-
s3 = new S3({ region });
47+
s3 = new S3({ logger, region, requestStreamBufferSize: 8 * 1024 });
48+
s3_noRequestBuffer = new S3({ logger, region });
2549
s3_noChecksum = new S3({
2650
region,
2751
requestChecksumCalculation: "WHEN_REQUIRED",
@@ -38,7 +62,7 @@ describe("S3 checksums", () => {
3862
expect(reqHeader).toEqual("CRC32");
3963
}
4064
if (resHeader) {
41-
expect(resHeader).toEqual("7YLNEQ==");
65+
expect(resHeader.length).toBeGreaterThanOrEqual(8);
4266
}
4367
return r;
4468
},
@@ -52,10 +76,152 @@ describe("S3 checksums", () => {
5276
await s3.putObject({ Bucket, Key, Body: "abcd" });
5377
});
5478

79+
it("checksums work with empty objects", async () => {
80+
await s3.putObject({
81+
Bucket,
82+
Key: Key + "empty",
83+
Body: stream(0, 0),
84+
ContentLength: 0,
85+
});
86+
const get = await s3.getObject({ Bucket, Key: Key + "empty" });
87+
expect(get.Body).toBeInstanceOf(ChecksumStream);
88+
});
89+
5590
it("an object should have checksum by default", async () => {
56-
await s3.getObject({ Bucket, Key });
91+
const get = await s3.getObject({ Bucket, Key });
92+
expect(get.Body).toBeInstanceOf(ChecksumStream);
5793
});
5894

95+
describe("PUT operations", () => {
96+
it("S3 throws an error if chunks are too small, because request buffering is off by default", async () => {
97+
await s3_noRequestBuffer
98+
.putObject({
99+
Bucket,
100+
Key: Key + "small-chunks",
101+
Body: stream(24 * 1024, 8),
102+
ContentLength: 24 * 1024,
103+
})
104+
.catch((e) => {
105+
expect(String(e)).toContain(
106+
"InvalidChunkSizeError: Only the last chunk is allowed to have a size less than 8192 bytes"
107+
);
108+
});
109+
expect.hasAssertions();
110+
});
111+
it("should assist user input streams by buffering to the minimum 8kb required by S3", async () => {
112+
await s3.putObject({
113+
Bucket,
114+
Key: Key + "small-chunks",
115+
Body: stream(24 * 1024, 8),
116+
ContentLength: 24 * 1024,
117+
});
118+
expect(logger.warn).toHaveBeenCalledWith(
119+
`@smithy/util-stream - stream chunk size 8 is below threshold of 8192, automatically buffering.`
120+
);
121+
const get = await s3.getObject({
122+
Bucket,
123+
Key: Key + "small-chunks",
124+
});
125+
expect((await get.Body?.transformToByteArray())?.byteLength).toEqual(24 * 1024);
126+
});
127+
it("should be able to write an object with a webstream body (using fetch handler without checksum)", async () => {
128+
const handler = s3_noChecksum.config.requestHandler;
129+
s3_noChecksum.config.requestHandler = new FetchHttpHandler();
130+
await s3_noChecksum.putObject({
131+
Bucket,
132+
Key: Key + "small-chunks-webstream",
133+
Body: webStream(24 * 1024, 512),
134+
ContentLength: 24 * 1024,
135+
});
136+
s3_noChecksum.config.requestHandler = handler;
137+
const get = await s3.getObject({
138+
Bucket,
139+
Key: Key + "small-chunks-webstream",
140+
});
141+
expect((await get.Body?.transformToByteArray())?.byteLength).toEqual(24 * 1024);
142+
});
143+
it("@aws-sdk/lib-storage Upload should allow webstreams to be used", async () => {
144+
await new Upload({
145+
client: s3,
146+
params: {
147+
Bucket,
148+
Key: Key + "small-chunks-webstream-mpu",
149+
Body: webStream(6 * 1024 * 1024, 512),
150+
},
151+
}).done();
152+
const get = await s3.getObject({
153+
Bucket,
154+
Key: Key + "small-chunks-webstream-mpu",
155+
});
156+
expect((await get.Body?.transformToByteArray())?.byteLength).toEqual(6 * 1024 * 1024);
157+
});
158+
it("should allow streams to be used in a manually orchestrated MPU", async () => {
159+
const cmpu = await s3.createMultipartUpload({
160+
Bucket,
161+
Key: Key + "-mpu",
162+
});
163+
164+
const MB = 1024 * 1024;
165+
const up = [] as UploadPartCommandOutput[];
166+
167+
try {
168+
up.push(
169+
await s3.uploadPart({
170+
Bucket,
171+
Key: Key + "-mpu",
172+
UploadId: cmpu.UploadId,
173+
Body: stream(5 * MB, 1024),
174+
PartNumber: 1,
175+
ContentLength: 5 * MB,
176+
}),
177+
await s3.uploadPart({
178+
Bucket,
179+
Key: Key + "-mpu",
180+
UploadId: cmpu.UploadId,
181+
Body: stream(MB, 64),
182+
PartNumber: 2,
183+
ContentLength: MB,
184+
})
185+
);
186+
expect(logger.warn).toHaveBeenCalledWith(
187+
`@smithy/util-stream - stream chunk size 1024 is below threshold of 8192, automatically buffering.`
188+
);
189+
expect(logger.warn).toHaveBeenCalledWith(
190+
`@smithy/util-stream - stream chunk size 64 is below threshold of 8192, automatically buffering.`
191+
);
192+
193+
await s3.completeMultipartUpload({
194+
Bucket,
195+
Key: Key + "-mpu",
196+
UploadId: cmpu.UploadId,
197+
MultipartUpload: {
198+
Parts: up.map((part, i) => {
199+
return {
200+
PartNumber: i + 1,
201+
ETag: part.ETag,
202+
};
203+
}),
204+
},
205+
});
206+
207+
const go = await s3.getObject({
208+
Bucket,
209+
Key: Key + "-mpu",
210+
});
211+
expect((await go.Body?.transformToByteArray())?.byteLength).toEqual(6 * MB);
212+
213+
expect(go.$metadata.httpStatusCode).toEqual(200);
214+
} catch (e) {
215+
await s3.abortMultipartUpload({
216+
UploadId: cmpu.UploadId,
217+
Bucket,
218+
Key: Key + "-mpu",
219+
});
220+
throw e;
221+
}
222+
});
223+
}, 45_000);
224+
59225
describe("the stream returned by S3::getObject should function interchangeably between ChecksumStream and default streams", () => {
60226
it("when collecting the stream", async () => {
61227
const defaultStream = (await s3_noChecksum.getObject({ Bucket, Key })).Body as Readable;

packages/middleware-flexible-checksums/src/resolveFlexibleChecksumsConfig.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe(resolveFlexibleChecksumsConfig.name, () => {
2525
expect(resolvedConfig).toEqual({
2626
requestChecksumCalculation: DEFAULT_REQUEST_CHECKSUM_CALCULATION,
2727
responseChecksumValidation: DEFAULT_RESPONSE_CHECKSUM_VALIDATION,
28+
requestStreamBufferSize: 0,
2829
});
2930
expect(normalizeProvider).toHaveBeenCalledTimes(2);
3031
});
@@ -33,6 +34,7 @@ describe(resolveFlexibleChecksumsConfig.name, () => {
3334
const mockInput = {
3435
requestChecksumCalculation: RequestChecksumCalculation.WHEN_REQUIRED,
3536
responseChecksumValidation: ResponseChecksumValidation.WHEN_REQUIRED,
37+
requestStreamBufferSize: 0,
3638
};
3739
const resolvedConfig = resolveFlexibleChecksumsConfig(mockInput);
3840
expect(resolvedConfig).toEqual(mockInput);

packages/middleware-flexible-checksums/src/resolveFlexibleChecksumsConfig.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,29 @@ export interface FlexibleChecksumsInputConfig {
1818
* Determines when checksum validation will be performed on response payloads.
1919
*/
2020
responseChecksumValidation?: ResponseChecksumValidation | Provider<ResponseChecksumValidation>;
21+
22+
/**
23+
* Default 0 (off).
24+
*
25+
* When set to a value greater than or equal to 8192, sets the minimum number
26+
* of bytes to buffer into a chunk when processing input streams
27+
* with chunked encoding (that is, when request checksums are enabled).
28+
* A minimum of 8kb = 8 * 1024 is required, and 64kb or higher is recommended.
29+
*
30+
* See https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html.
31+
*
32+
* This has a slight performance penalty because it must wrap and buffer
33+
* your input stream.
34+
* You do not need to set this value if your stream already flows chunks
35+
* of 8kb or greater.
36+
*/
37+
requestStreamBufferSize?: number | false;
2138
}
2239

2340
export interface FlexibleChecksumsResolvedConfig {
2441
requestChecksumCalculation: Provider<RequestChecksumCalculation>;
2542
responseChecksumValidation: Provider<ResponseChecksumValidation>;
43+
requestStreamBufferSize: number;
2644
}
2745

2846
export const resolveFlexibleChecksumsConfig = <T>(
@@ -35,4 +53,5 @@ export const resolveFlexibleChecksumsConfig = <T>(
3553
responseChecksumValidation: normalizeProvider(
3654
input.responseChecksumValidation ?? DEFAULT_RESPONSE_CHECKSUM_VALIDATION
3755
),
56+
requestStreamBufferSize: Number(input.requestStreamBufferSize ?? 0),
3857
});

packages/middleware-sdk-s3/src/check-content-length-header.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,24 @@ describe("checkContentLengthHeaderMiddleware", () => {
130130

131131
expect(spy).not.toHaveBeenCalled();
132132
});
133+
134+
it("does not warn if uploading a payload of known length via alternate header x-amz-decoded-content-length", async () => {
135+
const handler = checkContentLengthHeader()(mockNextHandler, {});
136+
137+
await handler({
138+
request: {
139+
method: null,
140+
protocol: null,
141+
hostname: null,
142+
path: null,
143+
query: {},
144+
headers: {
145+
"x-amz-decoded-content-length": "5",
146+
},
147+
},
148+
input: {},
149+
});
150+
151+
expect(spy).not.toHaveBeenCalled();
152+
});
133153
});

packages/middleware-sdk-s3/src/check-content-length-header.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "@smithy/types";
1313

1414
const CONTENT_LENGTH_HEADER = "content-length";
15+
const DECODED_CONTENT_LENGTH_HEADER = "x-amz-decoded-content-length";
1516

1617
/**
1718
* @internal
@@ -28,7 +29,7 @@ export function checkContentLengthHeader(): FinalizeRequestMiddleware<any, any>
2829
const { request } = args;
2930

3031
if (HttpRequest.isInstance(request)) {
31-
if (!(CONTENT_LENGTH_HEADER in request.headers)) {
32+
if (!(CONTENT_LENGTH_HEADER in request.headers) && !(DECODED_CONTENT_LENGTH_HEADER in request.headers)) {
3233
const message = `Are you using a Stream of unknown length as the Body of a PutObject request? Consider using Upload instead from @aws-sdk/lib-storage.`;
3334
if (typeof context?.logger?.warn === "function" && !(context.logger instanceof NoOpLogger)) {
3435
context.logger.warn(message);

private/aws-client-api-test/src/client-interface-tests/client-s3/impl/initializeWithMaximalConfiguration.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export const initializeWithMaximalConfiguration = () => {
130130
requestChecksumCalculation: DEFAULT_REQUEST_CHECKSUM_CALCULATION,
131131
responseChecksumValidation: DEFAULT_RESPONSE_CHECKSUM_VALIDATION,
132132
userAgentAppId: "testApp",
133+
requestStreamBufferSize: 8 * 1024,
133134
};
134135

135136
const s3 = new S3Client(config);

0 commit comments

Comments
 (0)