Skip to content

Commit 7ee033e

Browse files
committed
improve composable-cache performance
1 parent d07c9f1 commit 7ee033e

File tree

3 files changed

+47
-43
lines changed

3 files changed

+47
-43
lines changed

.changeset/stale-ducks-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/aws": patch
3+
---
4+
5+
Improve composable-cache performance by avoiding unnecessary copies

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

Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,18 @@
11
import type { ComposableCacheEntry, ComposableCacheHandler } from "types/cache";
2-
import type { CacheValue } from "types/overrides";
32
import { writeTags } from "utils/cache";
43
import { fromReadableStream, toReadableStream } from "utils/stream";
54
import { debug } from "./logger";
65

7-
const pendingWritePromiseMap = new Map<
8-
string,
9-
Promise<CacheValue<"composable">>
10-
>();
6+
const pendingWritePromiseMap = new Map<string, Promise<ComposableCacheEntry>>();
117

128
export default {
139
async get(cacheKey: string) {
1410
try {
1511
// We first check if we have a pending write for this cache key
1612
// If we do, we return the pending promise instead of fetching the cache
17-
if (pendingWritePromiseMap.has(cacheKey)) {
18-
const stored = pendingWritePromiseMap.get(cacheKey);
19-
if (stored) {
20-
return stored.then((entry) => ({
21-
...entry,
22-
value: toReadableStream(entry.value),
23-
}));
24-
}
25-
}
13+
const stored = pendingWritePromiseMap.get(cacheKey);
14+
if (stored) return stored;
15+
2616
const result = await globalThis.incrementalCache.get(
2717
cacheKey,
2818
"composable",
@@ -69,28 +59,45 @@ export default {
6959
},
7060

7161
async set(cacheKey: string, pendingEntry: Promise<ComposableCacheEntry>) {
72-
const promiseEntry = pendingEntry.then(async (entry) => ({
73-
...entry,
74-
value: await fromReadableStream(entry.value),
75-
}));
76-
pendingWritePromiseMap.set(cacheKey, promiseEntry);
62+
const teedPromise = pendingEntry.then((entry) => {
63+
// Optimization: We avoid consuming and stringifying the stream here,
64+
// because it creates double copies just to be discarded when this function
65+
// ends. This avoids unnecessary memory usage, and reduces GC pressure.
66+
const [stream1, stream2] = entry.value.tee();
67+
return [
68+
{ ...entry, value: stream1 },
69+
{ ...entry, value: stream2 },
70+
] as const;
71+
});
7772

78-
const entry = await promiseEntry.finally(() => {
73+
pendingWritePromiseMap.set(
74+
cacheKey,
75+
teedPromise.then(([entry]) => entry),
76+
);
77+
78+
const [, entryForStorage] = await teedPromise.finally(() => {
7979
pendingWritePromiseMap.delete(cacheKey);
8080
});
81+
8182
await globalThis.incrementalCache.set(
8283
cacheKey,
8384
{
84-
...entry,
85-
value: entry.value,
85+
...entryForStorage,
86+
value: await fromReadableStream(entryForStorage.value),
8687
},
8788
"composable",
8889
);
90+
8991
if (globalThis.tagCache.mode === "original") {
9092
const storedTags = await globalThis.tagCache.getByPath(cacheKey);
91-
const tagsToWrite = entry.tags.filter((tag) => !storedTags.includes(tag));
93+
const tagsToWrite = [];
94+
for (const tag of entryForStorage.tags) {
95+
if (!storedTags.includes(tag)) {
96+
tagsToWrite.push({ tag, path: cacheKey });
97+
}
98+
}
9299
if (tagsToWrite.length > 0) {
93-
await writeTags(tagsToWrite.map((tag) => ({ tag, path: cacheKey })));
100+
await writeTags(tagsToWrite);
94101
}
95102
}
96103
},
@@ -100,12 +107,11 @@ export default {
100107
return;
101108
},
102109
async getExpiration(...tags: string[]) {
103-
if (globalThis.tagCache.mode === "nextMode") {
104-
return globalThis.tagCache.getLastRevalidated(tags);
105-
}
106110
// We always return 0 here, original tag cache are handled directly in the get part
107111
// TODO: We need to test this more, i'm not entirely sure that this is working as expected
108-
return 0;
112+
return globalThis.tagCache.mode === "nextMode"
113+
? globalThis.tagCache.getLastRevalidated(tags)
114+
: 0;
109115
},
110116
async expireTags(...tags: string[]) {
111117
if (globalThis.tagCache.mode === "nextMode") {
@@ -125,17 +131,14 @@ export default {
125131
}));
126132
}),
127133
);
128-
// We need to deduplicate paths, we use a set for that
129-
const setToWrite = new Set<{ path: string; tag: string }>();
134+
135+
const dedupeMap = new Map();
130136
for (const entry of pathsToUpdate.flat()) {
131-
setToWrite.add(entry);
137+
dedupeMap.set(`${entry.path}|${entry.tag}`, entry);
132138
}
133-
await writeTags(Array.from(setToWrite));
139+
await writeTags(Array.from(dedupeMap.values()));
134140
},
135141

136142
// This one is necessary for older versions of next
137-
async receiveExpiredTags(...tags: string[]) {
138-
// This function does absolutely nothing
139-
return;
140-
},
143+
async receiveExpiredTags() {},
141144
} satisfies ComposableCacheHandler;

packages/open-next/src/utils/stream.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,9 @@ export async function fromReadableStream(
2020
return Buffer.from(chunks[0]).toString(base64 ? "base64" : "utf8");
2121
}
2222

23-
// Pre-allocate buffer with exact size to avoid reallocation
24-
const buffer = Buffer.alloc(totalLength);
25-
let offset = 0;
26-
for (const chunk of chunks) {
27-
buffer.set(chunk, offset);
28-
offset += chunk.length;
29-
}
23+
// Use Buffer.concat which is more efficient than manual allocation and copy
24+
// It handles the allocation and copy in optimized native code
25+
const buffer = Buffer.concat(chunks, totalLength);
3026

3127
return buffer.toString(base64 ? "base64" : "utf8");
3228
}

0 commit comments

Comments
 (0)