Skip to content

Commit eaa9ef8

Browse files
conico974vicb
andauthored
Feat: Multi-tiered cache for aws (#699)
* implement a multi-tiered cache for aws * fix linting * changeset * review fix * Apply suggestions from code review Co-authored-by: Victor Berchet <[email protected]> * added comment --------- Co-authored-by: Victor Berchet <[email protected]>
1 parent 6884444 commit eaa9ef8

File tree

5 files changed

+185
-2
lines changed

5 files changed

+185
-2
lines changed

.changeset/perfect-coats-tell.md

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

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export class LRUCache<T> {
2+
private cache: Map<string, T> = new Map();
3+
4+
constructor(private maxSize: number) {}
5+
6+
get(key: string) {
7+
const result = this.cache.get(key);
8+
// We could have used .has to allow for nullish value to be stored but we don't need that right now
9+
if (result) {
10+
// By removing and setting the key again we ensure it's the most recently used
11+
this.cache.delete(key);
12+
this.cache.set(key, result);
13+
}
14+
return result;
15+
}
16+
17+
set(key: string, value: any) {
18+
if (this.cache.size >= this.maxSize) {
19+
const firstKey = this.cache.keys().next().value;
20+
if (firstKey !== undefined) {
21+
this.cache.delete(firstKey);
22+
}
23+
}
24+
this.cache.set(key, value);
25+
}
26+
27+
delete(key: string) {
28+
this.cache.delete(key);
29+
}
30+
}

0 commit comments

Comments
 (0)