Skip to content

Commit 8a48c59

Browse files
committed
implement a multi-tiered cache for aws
1 parent 6884444 commit 8a48c59

File tree

3 files changed

+179
-2
lines changed

3 files changed

+179
-2
lines changed
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type { CacheValue, IncrementalCache } from "types/overrides";
2+
import S3Cache, { getAwsClient } from "./s3-lite";
3+
import { customFetchClient } from "utils/fetch";
4+
import { debug } from "../../adapters/logger";
5+
6+
// TTL for the local cache in milliseconds
7+
const localCacheTTL = process.env.OPEN_NEXT_LOCAL_CACHE_TTL
8+
? Number.parseInt(process.env.OPEN_NEXT_LOCAL_CACHE_TTL)
9+
: 0;
10+
// Maximum size of the local cache in nb of entries
11+
const maxCacheSize = process.env.OPEN_NEXT_LOCAL_CACHE_SIZE
12+
? Number.parseInt(process.env.OPEN_NEXT_LOCAL_CACHE_SIZE)
13+
: 1000;
14+
15+
class LRUCache {
16+
private cache: Map<
17+
string,
18+
{
19+
value: CacheValue<boolean>;
20+
lastModified: number;
21+
}
22+
> = new Map();
23+
private maxSize: number;
24+
25+
constructor(maxSize: number) {
26+
this.maxSize = maxSize;
27+
}
28+
29+
// isFetch is not used here, only used for typing
30+
get<T extends boolean = false>(key: string, isFetch?: T) {
31+
return this.cache.get(key) as {
32+
value: CacheValue<T>;
33+
lastModified: number;
34+
};
35+
}
36+
37+
set(key: string, value: any) {
38+
if (this.cache.size >= this.maxSize) {
39+
const firstKey = this.cache.keys().next().value;
40+
if (firstKey) {
41+
this.cache.delete(firstKey);
42+
}
43+
}
44+
this.cache.set(key, value);
45+
}
46+
47+
delete(key: string) {
48+
this.cache.delete(key);
49+
}
50+
}
51+
52+
const localCache = new LRUCache(maxCacheSize);
53+
54+
const awsFetch = (body: RequestInit["body"], type: "get" | "set" = "get") => {
55+
const { CACHE_BUCKET_REGION } = process.env;
56+
const client = getAwsClient();
57+
return customFetchClient(client)(
58+
`https://dynamodb.${CACHE_BUCKET_REGION}.amazonaws.com`,
59+
{
60+
method: "POST",
61+
headers: {
62+
"Content-Type": "application/x-amz-json-1.0",
63+
"X-Amz-Target": `DynamoDB_20120810.${
64+
type === "get" ? "GetItem" : "PutItem"
65+
}`,
66+
},
67+
body,
68+
},
69+
);
70+
};
71+
72+
const buildDynamoKey = (key: string) => {
73+
const { NEXT_BUILD_ID } = process.env;
74+
return `__meta_${NEXT_BUILD_ID}_${key}`;
75+
};
76+
77+
/**
78+
* This cache implementation uses a multi-tier cache with a local cache, a DynamoDB metadata cache and an S3 cache.
79+
* It uses the same DynamoDB table as the default tag cache and the same S3 bucket as the default incremental cache.
80+
* It will first check the local cache.
81+
* If the local cache is expired, it will check the DynamoDB metadata cache to see if the local cache is still valid.
82+
* Lastly it will check the S3 cache.
83+
*/
84+
const multiTierCache: IncrementalCache = {
85+
name: "multi-tier-ddb-s3",
86+
async get(key, isFetch) {
87+
// First we check the local cache
88+
const localCacheEntry = localCache.get(key, isFetch);
89+
if (localCacheEntry) {
90+
if (Date.now() - localCacheEntry.lastModified < localCacheTTL) {
91+
debug("Using local cache without checking ddb");
92+
return localCacheEntry;
93+
}
94+
try {
95+
// Here we'll check ddb metadata to see if the local cache is still valid
96+
const { CACHE_DYNAMO_TABLE } = process.env;
97+
const result = await awsFetch(
98+
JSON.stringify({
99+
TableName: CACHE_DYNAMO_TABLE,
100+
Key: {
101+
path: { S: buildDynamoKey(key) },
102+
tag: { S: buildDynamoKey(key) },
103+
},
104+
}),
105+
);
106+
if (result.status === 200) {
107+
const data = await result.json();
108+
const hasBeenDeleted = data.Item?.deleted?.BOOL;
109+
if (hasBeenDeleted) {
110+
localCache.delete(key);
111+
return { value: undefined, lastModified: 0 };
112+
}
113+
// If the metadata is older than the local cache, we can use the local cache
114+
// If it's not found we assume that no write has been done yet and we can use the local cache
115+
const lastModified = data.Item?.revalidatedAt?.N
116+
? Number.parseInt(data.Item.revalidatedAt.N)
117+
: 0;
118+
if (lastModified <= localCacheEntry.lastModified) {
119+
debug("Using local cache after checking ddb");
120+
return localCacheEntry;
121+
}
122+
}
123+
} catch (e) {
124+
debug("Failed to get metadata from ddb", e);
125+
}
126+
}
127+
const result = await S3Cache.get(key, isFetch);
128+
if (result.value) {
129+
localCache.set(key, {
130+
value: result.value,
131+
lastModified: result.lastModified ?? Date.now(),
132+
});
133+
}
134+
return result;
135+
},
136+
async set(key, value, isFetch) {
137+
const revalidatedAt = Date.now();
138+
await S3Cache.set(key, value, isFetch);
139+
await awsFetch(
140+
JSON.stringify({
141+
TableName: process.env.CACHE_DYNAMO_TABLE,
142+
Item: {
143+
tag: { S: buildDynamoKey(key) },
144+
path: { S: buildDynamoKey(key) },
145+
revalidatedAt: { N: String(revalidatedAt) },
146+
},
147+
}),
148+
"set",
149+
);
150+
localCache.set(key, {
151+
value,
152+
lastModified: revalidatedAt,
153+
});
154+
},
155+
async delete(key) {
156+
await S3Cache.delete(key);
157+
await awsFetch(
158+
JSON.stringify({
159+
TableName: process.env.CACHE_DYNAMO_TABLE,
160+
Item: {
161+
tag: { S: buildDynamoKey(key) },
162+
path: { S: buildDynamoKey(key) },
163+
deleted: { BOOL: true },
164+
},
165+
}),
166+
"set",
167+
);
168+
localCache.delete(key);
169+
},
170+
};
171+
172+
export default multiTierCache;

packages/open-next/src/overrides/incrementalCache/s3-lite.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { parseNumberFromEnv } from "../../adapters/util";
1111

1212
let awsClient: AwsClient | null = null;
1313

14-
const getAwsClient = () => {
14+
export const getAwsClient = () => {
1515
const { CACHE_BUCKET_REGION } = process.env;
1616
if (awsClient) {
1717
return awsClient;

packages/open-next/src/types/open-next.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,12 @@ export interface MiddlewareResult
136136

137137
export type IncludedQueue = "sqs" | "sqs-lite" | "direct" | "dummy";
138138

139-
export type IncludedIncrementalCache = "s3" | "s3-lite" | "fs-dev" | "dummy";
139+
export type IncludedIncrementalCache =
140+
| "s3"
141+
| "s3-lite"
142+
| "multi-tier-ddb-s3"
143+
| "fs-dev"
144+
| "dummy";
140145

141146
export type IncludedTagCache =
142147
| "dynamodb"

0 commit comments

Comments
 (0)