Skip to content

Commit b7c8f47

Browse files
authored
Perf Reduce s3 calls (#295)
* merge cache files into a single file * incremental cache use the single cache file * update doc
1 parent 48f5e83 commit b7c8f47

File tree

3 files changed

+169
-133
lines changed

3 files changed

+169
-133
lines changed

docs/pages/inner_workings/isr.mdx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,21 @@ They can also be called on fetch requests if the `cache` option is not set to `n
4444

4545
There is also some cost associated to deployment since you need to upload the cache to S3 and upload the tags to DynamoDB.
4646

47-
For the examples here, let's assume an app route with a 5 minute revalidation delay in us-east-1 (App uses 3 instead of 2 `GetObject`). This is assuming you get constant traffic to the route (If you get no traffic, you will only pay for the storage cost).
47+
For the examples here, let's assume an app route with a 5 minute revalidation delay in us-east-1. This is assuming you get constant traffic to the route (If you get no traffic, you will only pay for the storage cost).
4848

4949
##### S3
50-
- Each `get` request to the cache will result in at least 1 `ListRequest` in S3 and between 2 and 3 `GetObject`
50+
- Each `get` request to the cache will result in at least 1 `GetObject`
5151

5252
```
53-
List Request cost - 8,640 requests * $0.005 per 1,000 requests = $0.0432
54-
GetObject cost - 8,640 requests * $0.0004 per 1,000 requests * 3 = $0.010368
55-
Total cost - $0.053568 per route per month
53+
GetObject cost - 8,640 requests * $0.0004 per 1,000 requests = $0.003456
54+
Total cost - $0.003456 per route per month
5655
```
5756

58-
- Each `set` request to the cache will result in 2 to 3 `PutObject` in S3
57+
- Each `set` request to the cache will result in 1 `PutObject` in S3
5958

6059
```
61-
PutObject cost - 8,640 requests * $0.005 per 1,000 requests * 3 = $0.1296
62-
Total cost - $0.1296 per route per month
60+
PutObject cost - 8,640 requests * $0.005 per 1,000 requests = $0.0432
61+
Total cost - $0.0432 per route per month
6362
```
6463

6564
You can then calculate the cost based on your usage and the [S3 pricing](https://aws.amazon.com/s3/pricing/)

packages/open-next/src/adapters/cache.ts

Lines changed: 106 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
ListObjectsV2Command,
1010
PutObjectCommand,
1111
PutObjectCommandInput,
12-
PutObjectCommandOutput,
1312
S3Client,
1413
} from "@aws-sdk/client-s3";
1514
import path from "path";
@@ -88,17 +87,38 @@ interface CacheHandlerValue {
8887
value: IncrementalCacheValue | null;
8988
}
9089

91-
type CacheExtension =
92-
| "json"
93-
| "html"
94-
| "rsc"
95-
| "body"
96-
| "meta"
97-
| "fetch"
98-
| "redirect";
90+
type Extension = "cache" | "fetch";
91+
92+
interface Meta {
93+
status?: number;
94+
headers?: Record<string, undefined | string | string[]>;
95+
}
96+
type S3CachedFile =
97+
| {
98+
type: "redirect";
99+
props?: Object;
100+
meta?: Meta;
101+
}
102+
| {
103+
type: "page";
104+
html: string;
105+
json: Object;
106+
meta?: Meta;
107+
}
108+
| {
109+
type: "app";
110+
html: string;
111+
rsc: string;
112+
meta?: Meta;
113+
}
114+
| {
115+
type: "route";
116+
body: string;
117+
meta?: Meta;
118+
};
99119

100120
/** Beginning single backslash is intentional, to look for the dot + the extension. Do not escape it again. */
101-
const CACHE_EXTENSION_REGEX = /\.(json|html|rsc|body|meta|fetch|redirect)$/;
121+
const CACHE_EXTENSION_REGEX = /\.(cache|fetch)$/;
102122

103123
export function hasCacheExtension(key: string) {
104124
return CACHE_EXTENSION_REGEX.test(key);
@@ -136,18 +156,15 @@ export default class S3Cache {
136156
}
137157
const isFetchCache =
138158
typeof options === "object" ? options.fetchCache : options;
139-
const keys = await this.listS3Object(key);
140-
if (keys.length === 0) return null;
141-
debug("keys", keys);
142159
return isFetchCache
143-
? this.getFetchCache(key, keys)
144-
: this.getIncrementalCache(key, keys);
160+
? this.getFetchCache(key)
161+
: this.getIncrementalCache(key);
145162
}
146163

147-
async getFetchCache(key: string, keys: string[]) {
164+
async getFetchCache(key: string) {
148165
debug("get fetch cache", { key });
149166
try {
150-
const { Body, LastModified } = await this.getS3Object(key, "fetch", keys);
167+
const { Body, LastModified } = await this.getS3Object(key, "fetch");
151168
const lastModified = await this.getHasRevalidatedTags(
152169
key,
153170
LastModified?.getTime(),
@@ -169,96 +186,59 @@ export default class S3Cache {
169186
}
170187
}
171188

172-
async getIncrementalCache(
173-
key: string,
174-
keys: string[],
175-
): Promise<CacheHandlerValue | null> {
176-
if (keys.includes(this.buildS3Key(key, "body"))) {
177-
debug("get body cache ", { key });
178-
try {
179-
const [{ Body, LastModified }, { Body: MetaBody }] = await Promise.all([
180-
this.getS3Object(key, "body", keys),
181-
this.getS3Object(key, "meta", keys),
182-
]);
183-
const body = await Body?.transformToByteArray();
184-
const meta = JSON.parse((await MetaBody?.transformToString()) ?? "{}");
185-
189+
async getIncrementalCache(key: string): Promise<CacheHandlerValue | null> {
190+
try {
191+
const { Body, LastModified } = await this.getS3Object(key, "cache");
192+
const cacheData = JSON.parse(
193+
(await Body?.transformToString()) ?? "{}",
194+
) as S3CachedFile;
195+
const meta = cacheData.meta;
196+
const lastModified = await this.getHasRevalidatedTags(
197+
key,
198+
LastModified?.getTime(),
199+
);
200+
if (lastModified === -1) {
201+
// If some tags are stale we need to force revalidation
202+
return null;
203+
}
204+
if (cacheData.type === "route") {
186205
return {
187206
lastModified: LastModified?.getTime(),
188207
value: {
189208
kind: "ROUTE",
190-
body: Buffer.from(body ?? Buffer.alloc(0)),
191-
status: meta.status,
192-
headers: meta.headers,
209+
body: Buffer.from(cacheData.body ?? Buffer.alloc(0)),
210+
status: meta?.status,
211+
headers: meta?.headers,
193212
},
194213
} as CacheHandlerValue;
195-
} catch (e) {
196-
error("Failed to get body cache", e);
197-
}
198-
return null;
199-
}
200-
201-
if (keys.includes(this.buildS3Key(key, "html"))) {
202-
const isJson = keys.includes(this.buildS3Key(key, "json"));
203-
const isRsc = keys.includes(this.buildS3Key(key, "rsc"));
204-
debug("get html cache ", { key, isJson, isRsc });
205-
if (!isJson && !isRsc) return null;
206-
207-
try {
208-
const [{ Body, LastModified }, { Body: PageBody }, { Body: MetaBody }] =
209-
await Promise.all([
210-
this.getS3Object(key, "html", keys),
211-
this.getS3Object(key, isJson ? "json" : "rsc", keys),
212-
this.getS3Object(key, "meta", keys),
213-
]);
214-
const lastModified = await this.getHasRevalidatedTags(
215-
key,
216-
LastModified?.getTime(),
217-
);
218-
if (lastModified === -1) {
219-
// If some tags are stale we need to force revalidation
220-
return null;
221-
}
222-
const meta = JSON.parse((await MetaBody?.transformToString()) ?? "{}");
214+
} else if (cacheData.type === "page" || cacheData.type === "app") {
223215
return {
224-
lastModified,
216+
lastModified: LastModified?.getTime(),
225217
value: {
226218
kind: "PAGE",
227-
html: (await Body?.transformToString()) ?? "",
228-
pageData: isJson
229-
? JSON.parse((await PageBody?.transformToString()) ?? "{}")
230-
: await PageBody?.transformToString(),
231-
status: meta.status,
232-
headers: meta.headers,
219+
html: cacheData.html,
220+
pageData:
221+
cacheData.type === "page" ? cacheData.json : cacheData.rsc,
222+
status: meta?.status,
223+
headers: meta?.headers,
233224
},
234225
} as CacheHandlerValue;
235-
} catch (e) {
236-
error("Failed to get html cache", e);
237-
}
238-
return null;
239-
}
240-
241-
// Check for redirect last. This way if a page has been regenerated
242-
// after having been redirected, we'll get the page data
243-
if (keys.includes(this.buildS3Key(key, "redirect"))) {
244-
debug("get redirect cache", { key });
245-
try {
246-
const { Body, LastModified } = await this.getS3Object(
247-
key,
248-
"redirect",
249-
keys,
250-
);
226+
} else if (cacheData.type === "redirect") {
251227
return {
252228
lastModified: LastModified?.getTime(),
253-
value: JSON.parse((await Body?.transformToString()) ?? "{}"),
254-
};
255-
} catch (e) {
256-
error("Failed to get redirect cache", e);
229+
value: {
230+
kind: "REDIRECT",
231+
props: cacheData.props,
232+
},
233+
} as CacheHandlerValue;
234+
} else {
235+
error("Unknown cache type", cacheData);
236+
return null;
257237
}
238+
} catch (e) {
239+
error("Failed to get body cache", e);
258240
return null;
259241
}
260-
261-
return null;
262242
}
263243

264244
async set(key: string, data?: IncrementalCacheValue): Promise<void> {
@@ -267,37 +247,44 @@ export default class S3Cache {
267247
}
268248
if (data?.kind === "ROUTE") {
269249
const { body, status, headers } = data;
270-
await Promise.all([
271-
this.putS3Object(key, "body", body),
272-
this.putS3Object(key, "meta", JSON.stringify({ status, headers })),
273-
]);
250+
this.putS3Object(
251+
key,
252+
"cache",
253+
JSON.stringify({
254+
type: "route",
255+
body: body.toString("utf8"),
256+
meta: {
257+
status,
258+
headers,
259+
},
260+
} as S3CachedFile),
261+
);
274262
} else if (data?.kind === "PAGE") {
275263
const { html, pageData } = data;
276264
const isAppPath = typeof pageData === "string";
277-
let metaPromise: Promise<PutObjectCommandOutput | void> =
278-
Promise.resolve();
279-
if (data.status || data.headers) {
280-
metaPromise = this.putS3Object(
281-
key,
282-
"meta",
283-
JSON.stringify({ status: data.status, headers: data.headers }),
284-
);
285-
}
286-
await Promise.all([
287-
this.putS3Object(key, "html", html),
288-
this.putS3Object(
289-
key,
290-
isAppPath ? "rsc" : "json",
291-
isAppPath ? pageData : JSON.stringify(pageData),
292-
),
293-
metaPromise,
294-
]);
265+
this.putS3Object(
266+
key,
267+
"cache",
268+
JSON.stringify({
269+
type: isAppPath ? "app" : "page",
270+
html,
271+
rsc: isAppPath ? pageData : undefined,
272+
json: isAppPath ? undefined : pageData,
273+
meta: { status: data.status, headers: data.headers },
274+
} as S3CachedFile),
275+
);
295276
} else if (data?.kind === "FETCH") {
296277
await this.putS3Object(key, "fetch", JSON.stringify(data));
297278
} else if (data?.kind === "REDIRECT") {
298-
// delete potential page data if we're redirecting
299-
await this.deleteS3Objects(key);
300-
await this.putS3Object(key, "redirect", JSON.stringify(data));
279+
// // delete potential page data if we're redirecting
280+
await this.putS3Object(
281+
key,
282+
"cache",
283+
JSON.stringify({
284+
type: "redirect",
285+
props: data.props,
286+
} as S3CachedFile),
287+
);
301288
} else if (data === null || data === undefined) {
302289
await this.deleteS3Objects(key);
303290
}
@@ -466,7 +453,7 @@ export default class S3Cache {
466453

467454
// S3 handling
468455

469-
private buildS3Key(key: string, extension: CacheExtension) {
456+
private buildS3Key(key: string, extension: Extension) {
470457
return path.posix.join(
471458
CACHE_BUCKET_KEY_PREFIX ?? "",
472459
extension === "fetch" ? "__fetch" : "",
@@ -491,14 +478,8 @@ export default class S3Cache {
491478
return (Contents ?? []).map(({ Key }) => Key) as string[];
492479
}
493480

494-
private async getS3Object(
495-
key: string,
496-
extension: CacheExtension,
497-
keys: string[],
498-
) {
481+
private async getS3Object(key: string, extension: Extension) {
499482
try {
500-
if (!keys.includes(this.buildS3Key(key, extension)))
501-
return { Body: null, LastModified: null };
502483
const result = await this.client.send(
503484
new GetObjectCommand({
504485
Bucket: CACHE_BUCKET_NAME,
@@ -514,7 +495,7 @@ export default class S3Cache {
514495

515496
private putS3Object(
516497
key: string,
517-
extension: CacheExtension,
498+
extension: Extension,
518499
value: PutObjectCommandInput["Body"],
519500
) {
520501
return this.client.send(

0 commit comments

Comments
 (0)