@trieb.work/nextjs-turbo-redis-cache 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +50 -0
- package/.github/workflows/release.yml +30 -0
- package/.prettierrc.json +6 -0
- package/CHANGELOG.md +0 -0
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/commitlint.config.cjs +4 -0
- package/dist/CachedHandler.d.ts +9 -0
- package/dist/CachedHandler.js +28 -0
- package/dist/DeduplicatedRequestHandler.d.ts +9 -0
- package/dist/DeduplicatedRequestHandler.js +48 -0
- package/dist/RedisStringsHandler.d.ts +48 -0
- package/dist/RedisStringsHandler.js +215 -0
- package/dist/SyncedMap.d.ts +51 -0
- package/dist/SyncedMap.js +203 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +8 -0
- package/eslint.config.mjs +26 -0
- package/hooks/commit-msg +10 -0
- package/hooks/pre-commit +11 -0
- package/package.json +54 -0
- package/release.config.cjs +31 -0
- package/scripts/prepare.sh +7 -0
- package/scripts/setup-git.sh +8 -0
- package/scripts/setup-repo-name.sh +17 -0
- package/scripts/vitest-run-staged.cjs +44 -0
- package/src/CachedHandler.ts +25 -0
- package/src/DeduplicatedRequestHandler.ts +61 -0
- package/src/RedisStringsHandler.ts +344 -0
- package/src/SyncedMap.ts +298 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +2 -0
- package/tsconfig.json +14 -0
- package/vite.config.ts +18 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
permissions:
|
|
15
|
+
pull-requests: write # Grant write access to pull request comments
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- name: Checkout code
|
|
19
|
+
uses: actions/checkout@v3
|
|
20
|
+
|
|
21
|
+
- name: Setup Node.js
|
|
22
|
+
uses: actions/setup-node@v3
|
|
23
|
+
with:
|
|
24
|
+
node-version: '20'
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: npm ci
|
|
28
|
+
|
|
29
|
+
- name: Run lint
|
|
30
|
+
run: npm run lint
|
|
31
|
+
|
|
32
|
+
- name: Run tests
|
|
33
|
+
run: npm run test
|
|
34
|
+
|
|
35
|
+
- name: Code Coverage Comments
|
|
36
|
+
uses: kcjpop/coverage-comments@v2.2
|
|
37
|
+
with:
|
|
38
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
39
|
+
coverage-file: './coverage/lcov.info'
|
|
40
|
+
|
|
41
|
+
- name: Build project
|
|
42
|
+
run: npm run build
|
|
43
|
+
|
|
44
|
+
- name: Dry run the release
|
|
45
|
+
env:
|
|
46
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GitHub token for Semantic Release
|
|
47
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # NPM token for publishing
|
|
48
|
+
run: |
|
|
49
|
+
npx semantic-release --dry-run
|
|
50
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
- beta
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
release:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- name: Checkout code
|
|
15
|
+
uses: actions/checkout@v3
|
|
16
|
+
|
|
17
|
+
- name: Setup Node.js
|
|
18
|
+
uses: actions/setup-node@v3
|
|
19
|
+
with:
|
|
20
|
+
node-version: '20'
|
|
21
|
+
cache: 'npm'
|
|
22
|
+
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: npm ci
|
|
25
|
+
|
|
26
|
+
- name: Run Semantic Release
|
|
27
|
+
env:
|
|
28
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GitHub token for Semantic Release
|
|
29
|
+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # NPM token for publishing
|
|
30
|
+
run: npx semantic-release
|
package/.prettierrc.json
ADDED
package/CHANGELOG.md
ADDED
|
File without changes
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 trieb.work | cloud consulting
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# nextjs-turbo-redis-cache
|
|
2
|
+
|
|
3
|
+
The ultimate Redis caching solution for Next.js. Built for production-ready, large-scale projects, it delivers unparalleled performance and efficiency with features tailored for high-traffic applications.
|
|
4
|
+
|
|
5
|
+
Key Features:
|
|
6
|
+
|
|
7
|
+
- _Turbocharged Storage_: Avoids base64 encoding to save ~33% of storage space compared to traditional implementations.
|
|
8
|
+
- _Batch Tag Invalidation_: Groups and optimizes delete operations for minimal Redis stress.
|
|
9
|
+
- _Request Deduplication_: Prevents redundant Redis get calls, ensuring faster response times.
|
|
10
|
+
- _In-Memory Caching_: Includes local caching for Redis get operations to reduce latency further.
|
|
11
|
+
- _Efficient Tag Management_: in-memory tags map for lightning-fast revalidate operations with minimal Redis overhead.
|
|
12
|
+
- _Intelligent Key-Space Notifications_: Automatic update of in-memory tags map for expired or evicted keys.
|
|
13
|
+
|
|
14
|
+
## Describe Options
|
|
15
|
+
|
|
16
|
+
TODO
|
|
17
|
+
|
|
18
|
+
## Getting started
|
|
19
|
+
|
|
20
|
+
TODO add description for how to use it
|
|
21
|
+
|
|
22
|
+
## Consistency
|
|
23
|
+
|
|
24
|
+
To understand consistency levels of this caching implementation we first have to understand the consistency of redis itself:
|
|
25
|
+
Redis executes commands in a single-threaded manner. This ensures that all operations are processed sequentially, so clients always see a consistent view of the data.
|
|
26
|
+
But depending on the setup of redis this can change:
|
|
27
|
+
|
|
28
|
+
- Strong consistency: only for single node setup
|
|
29
|
+
- Eventual consistency: In a master-replica setup (strong consistency only while there is no failover)
|
|
30
|
+
- Eventual consistency: In Redis Cluster mode
|
|
31
|
+
|
|
32
|
+
Consistency levels of Caching Handler:
|
|
33
|
+
If Redis is used in a single node setup and Request Deduplication is turned off, only then the caching handler will have strong consistency.
|
|
34
|
+
|
|
35
|
+
If using Request Deduplication, the strong consistency is not guaranteed anymore. The following sequence can happen with request deduplication:
|
|
36
|
+
Instance 1: call set A 1
|
|
37
|
+
Instance 1: served set A 1
|
|
38
|
+
Instance 2: call get A
|
|
39
|
+
Instance 1: call delete A
|
|
40
|
+
Instance 2: call get A
|
|
41
|
+
Instance 2: served get A -> 1
|
|
42
|
+
Instance 2: served get A -> 1 (served 1 but should already be deleted)
|
|
43
|
+
Instance 1: served delete A
|
|
44
|
+
|
|
45
|
+
The time window for this eventual consistency to occur is typically around the length of a single redis command. Depending on your load ranging from 5ms to max 100ms.
|
|
46
|
+
If using local in-memory caching (Enabled by RedisStringsHandler option inMemoryCachingTime), the window for this eventual consistency to occur can be even higher because additionally also synchronization messages have to get delivered. Depending on your load typically ranging around 50ms to max of 120ms.
|
|
47
|
+
|
|
48
|
+
Since all caching calls in one api/page/server action request is always served by the same instance this problem will not occur inside a single request but rather in a combination of multiple parallel requests. The probability that this will occur for a single user during a request sequence is very low, since typically a single user will not make the follow up request during this small time window of typically 50ms. To further mitigate the Problem and increase performance (increase local in-memory cache hit ratio) make sure that your load balancer will always serve one user to the same instance (sticky sessions).
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
5. Run `npm install` to install the dependencies
|
|
53
|
+
6. Run `npm run build` to build the project
|
|
54
|
+
7. Run `npm run dev` to develop the project
|
|
55
|
+
8. Run `npm run test` to test the project
|
|
56
|
+
9. Checkout into a new branch (main is protected)
|
|
57
|
+
10. Change code and commit it using conventional commit. Staged code will get checked
|
|
58
|
+
11. Push and create a PR (against main or beta) to run CI
|
|
59
|
+
12. Merge to main or beta to create a release or pre-release
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { CacheHandler } from "next/dist/server/lib/incremental-cache";
|
|
2
|
+
import RedisStringsHandler, { CreateRedisStringsHandlerOptions } from "./RedisStringsHandler";
|
|
3
|
+
export default class CachedHandler implements CacheHandler {
|
|
4
|
+
constructor(options: CreateRedisStringsHandlerOptions);
|
|
5
|
+
get(...args: Parameters<RedisStringsHandler["get"]>): ReturnType<RedisStringsHandler["get"]>;
|
|
6
|
+
set(...args: Parameters<RedisStringsHandler["set"]>): ReturnType<RedisStringsHandler["set"]>;
|
|
7
|
+
revalidateTag(...args: Parameters<RedisStringsHandler["revalidateTag"]>): ReturnType<RedisStringsHandler["revalidateTag"]>;
|
|
8
|
+
resetRequestCache(...args: Parameters<RedisStringsHandler["resetRequestCache"]>): ReturnType<RedisStringsHandler["resetRequestCache"]>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const RedisStringsHandler_1 = __importDefault(require("./RedisStringsHandler"));
|
|
7
|
+
let cachedHandler;
|
|
8
|
+
class CachedHandler {
|
|
9
|
+
constructor(options) {
|
|
10
|
+
if (!cachedHandler) {
|
|
11
|
+
console.log("created cached handler");
|
|
12
|
+
cachedHandler = new RedisStringsHandler_1.default(options);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
get(...args) {
|
|
16
|
+
return cachedHandler.get(...args);
|
|
17
|
+
}
|
|
18
|
+
set(...args) {
|
|
19
|
+
return cachedHandler.set(...args);
|
|
20
|
+
}
|
|
21
|
+
revalidateTag(...args) {
|
|
22
|
+
return cachedHandler.revalidateTag(...args);
|
|
23
|
+
}
|
|
24
|
+
resetRequestCache(...args) {
|
|
25
|
+
return cachedHandler.resetRequestCache(...args);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
exports.default = CachedHandler;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SyncedMap } from './SyncedMap';
|
|
2
|
+
export declare class DeduplicatedRequestHandler<T extends (...args: [never, never]) => Promise<K>, K> {
|
|
3
|
+
private inMemoryDeduplicationCache;
|
|
4
|
+
private cachingTimeMs;
|
|
5
|
+
private fn;
|
|
6
|
+
constructor(fn: T, cachingTimeMs: number, inMemoryDeduplicationCache: SyncedMap<Promise<K>>);
|
|
7
|
+
seedRequestReturn(key: string, value: K): void;
|
|
8
|
+
deduplicatedFunction: (key: string) => T;
|
|
9
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DeduplicatedRequestHandler = void 0;
|
|
4
|
+
class DeduplicatedRequestHandler {
|
|
5
|
+
constructor(fn, cachingTimeMs, inMemoryDeduplicationCache) {
|
|
6
|
+
// Method to handle deduplicated requests
|
|
7
|
+
this.deduplicatedFunction = (key) => {
|
|
8
|
+
//eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
9
|
+
const self = this;
|
|
10
|
+
const dedupedFn = async (...args) => {
|
|
11
|
+
// If there's already a pending request with the same key, return it
|
|
12
|
+
if (self.inMemoryDeduplicationCache &&
|
|
13
|
+
self.inMemoryDeduplicationCache.has(key)) {
|
|
14
|
+
const res = await self.inMemoryDeduplicationCache
|
|
15
|
+
.get(key)
|
|
16
|
+
.then((v) => structuredClone(v));
|
|
17
|
+
return res;
|
|
18
|
+
}
|
|
19
|
+
// If no pending request, call the original function and store the promise
|
|
20
|
+
const promise = self.fn(...args);
|
|
21
|
+
self.inMemoryDeduplicationCache.set(key, promise);
|
|
22
|
+
try {
|
|
23
|
+
const result = await promise;
|
|
24
|
+
return structuredClone(result);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
// Once the promise is resolved/rejected, remove it from the map
|
|
28
|
+
setTimeout(() => {
|
|
29
|
+
self.inMemoryDeduplicationCache.delete(key);
|
|
30
|
+
}, self.cachingTimeMs);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
return dedupedFn;
|
|
34
|
+
};
|
|
35
|
+
this.fn = fn;
|
|
36
|
+
this.cachingTimeMs = cachingTimeMs;
|
|
37
|
+
this.inMemoryDeduplicationCache = inMemoryDeduplicationCache;
|
|
38
|
+
}
|
|
39
|
+
// Method to manually seed a result into the cache
|
|
40
|
+
seedRequestReturn(key, value) {
|
|
41
|
+
const resultPromise = new Promise((res) => res(value));
|
|
42
|
+
this.inMemoryDeduplicationCache.set(key, resultPromise);
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
this.inMemoryDeduplicationCache.delete(key);
|
|
45
|
+
}, this.cachingTimeMs);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
exports.DeduplicatedRequestHandler = DeduplicatedRequestHandler;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { commandOptions, createClient } from 'redis';
|
|
2
|
+
import { CacheHandler, CacheHandlerValue, IncrementalCache } from 'next/dist/server/lib/incremental-cache';
|
|
3
|
+
export type CommandOptions = ReturnType<typeof commandOptions>;
|
|
4
|
+
type GetParams = Parameters<IncrementalCache['get']>;
|
|
5
|
+
type SetParams = Parameters<IncrementalCache['set']>;
|
|
6
|
+
type RevalidateParams = Parameters<IncrementalCache['revalidateTag']>;
|
|
7
|
+
export type Client = ReturnType<typeof createClient>;
|
|
8
|
+
export type CreateRedisStringsHandlerOptions = {
|
|
9
|
+
database?: number;
|
|
10
|
+
keyPrefix?: string;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
revalidateTagQuerySize?: number;
|
|
13
|
+
sharedTagsKey?: string;
|
|
14
|
+
avgResyncIntervalMs?: number;
|
|
15
|
+
redisGetDeduplication?: boolean;
|
|
16
|
+
inMemoryCachingTime?: number;
|
|
17
|
+
defaultStaleAge?: number;
|
|
18
|
+
estimateExpireAge?: (staleAge: number) => number;
|
|
19
|
+
maxMemoryCacheSize?: number;
|
|
20
|
+
};
|
|
21
|
+
export declare function getTimeoutRedisCommandOptions(timeoutMs: number): CommandOptions;
|
|
22
|
+
export default class RedisStringsHandler implements CacheHandler {
|
|
23
|
+
private maxMemoryCacheSize;
|
|
24
|
+
private client;
|
|
25
|
+
private sharedTagsMap;
|
|
26
|
+
private revalidatedTagsMap;
|
|
27
|
+
private inMemoryDeduplicationCache;
|
|
28
|
+
private redisGet;
|
|
29
|
+
private redisDeduplicationHandler;
|
|
30
|
+
private deduplicatedRedisGet;
|
|
31
|
+
private timeoutMs;
|
|
32
|
+
private keyPrefix;
|
|
33
|
+
private redisGetDeduplication;
|
|
34
|
+
private inMemoryCachingTime;
|
|
35
|
+
private defaultStaleAge;
|
|
36
|
+
private estimateExpireAge;
|
|
37
|
+
constructor({ maxMemoryCacheSize, database, keyPrefix, sharedTagsKey, timeoutMs, revalidateTagQuerySize, avgResyncIntervalMs, redisGetDeduplication, inMemoryCachingTime, defaultStaleAge, estimateExpireAge, }: CreateRedisStringsHandlerOptions);
|
|
38
|
+
resetRequestCache(...args: never[]): void;
|
|
39
|
+
private assertClientIsReady;
|
|
40
|
+
get(key: GetParams[0], ctx: GetParams[1]): Promise<(CacheHandlerValue & {
|
|
41
|
+
lastModified: number;
|
|
42
|
+
}) | null>;
|
|
43
|
+
set(key: SetParams[0], data: SetParams[1] & {
|
|
44
|
+
lastModified: number;
|
|
45
|
+
}, ctx: SetParams[2]): Promise<void>;
|
|
46
|
+
revalidateTag(tagOrTags: RevalidateParams[0]): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getTimeoutRedisCommandOptions = getTimeoutRedisCommandOptions;
|
|
4
|
+
const redis_1 = require("redis");
|
|
5
|
+
const SyncedMap_1 = require("./SyncedMap");
|
|
6
|
+
const DeduplicatedRequestHandler_1 = require("./DeduplicatedRequestHandler");
|
|
7
|
+
const NEXT_CACHE_IMPLICIT_TAG_ID = '_N_T_';
|
|
8
|
+
const REVALIDATED_TAGS_KEY = '__revalidated_tags__';
|
|
9
|
+
function isImplicitTag(tag) {
|
|
10
|
+
return tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID);
|
|
11
|
+
}
|
|
12
|
+
function getTimeoutRedisCommandOptions(timeoutMs) {
|
|
13
|
+
return (0, redis_1.commandOptions)({ signal: AbortSignal.timeout(timeoutMs) });
|
|
14
|
+
}
|
|
15
|
+
class RedisStringsHandler {
|
|
16
|
+
constructor({ maxMemoryCacheSize, database = process.env.VERCEL_ENV === 'production' ? 0 : 1, keyPrefix = process.env.VERCEL_URL || 'UNDEFINED_URL_', sharedTagsKey = '__sharedTags__', timeoutMs = 5000, revalidateTagQuerySize = 250, avgResyncIntervalMs = 60 * 60 * 1000, redisGetDeduplication = true, inMemoryCachingTime = 10000, defaultStaleAge = 60 * 60 * 24 * 14, estimateExpireAge = (staleAge) => process.env.VERCEL_ENV === 'preview' ? staleAge * 1.2 : staleAge * 2, }) {
|
|
17
|
+
this.maxMemoryCacheSize = maxMemoryCacheSize;
|
|
18
|
+
this.keyPrefix = keyPrefix;
|
|
19
|
+
this.timeoutMs = timeoutMs;
|
|
20
|
+
this.redisGetDeduplication = redisGetDeduplication;
|
|
21
|
+
this.inMemoryCachingTime = inMemoryCachingTime;
|
|
22
|
+
this.defaultStaleAge = defaultStaleAge;
|
|
23
|
+
this.estimateExpireAge = estimateExpireAge;
|
|
24
|
+
try {
|
|
25
|
+
this.client = (0, redis_1.createClient)({
|
|
26
|
+
...(database !== 0 ? { database } : {}),
|
|
27
|
+
url: process.env.REDISHOST
|
|
28
|
+
? `redis://${process.env.REDISHOST}:${process.env.REDISPORT}`
|
|
29
|
+
: 'redis://localhost:6379',
|
|
30
|
+
});
|
|
31
|
+
this.client.on('error', (error) => {
|
|
32
|
+
console.error('Redis client error', error);
|
|
33
|
+
});
|
|
34
|
+
this.client
|
|
35
|
+
.connect()
|
|
36
|
+
.then(() => {
|
|
37
|
+
console.info('Redis client connected.');
|
|
38
|
+
})
|
|
39
|
+
.catch((error) => {
|
|
40
|
+
console.error('Failed to connect Redis client:', error);
|
|
41
|
+
this.client.disconnect();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
console.error('Failed to initialize Redis client');
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
const filterKeys = (key) => key !== REVALIDATED_TAGS_KEY && key !== sharedTagsKey;
|
|
49
|
+
this.sharedTagsMap = new SyncedMap_1.SyncedMap({
|
|
50
|
+
client: this.client,
|
|
51
|
+
keyPrefix,
|
|
52
|
+
redisKey: sharedTagsKey,
|
|
53
|
+
database,
|
|
54
|
+
timeoutMs,
|
|
55
|
+
querySize: revalidateTagQuerySize,
|
|
56
|
+
filterKeys,
|
|
57
|
+
resyncIntervalMs: avgResyncIntervalMs -
|
|
58
|
+
avgResyncIntervalMs / 10 +
|
|
59
|
+
Math.random() * (avgResyncIntervalMs / 10),
|
|
60
|
+
});
|
|
61
|
+
this.revalidatedTagsMap = new SyncedMap_1.SyncedMap({
|
|
62
|
+
client: this.client,
|
|
63
|
+
keyPrefix,
|
|
64
|
+
redisKey: REVALIDATED_TAGS_KEY,
|
|
65
|
+
database,
|
|
66
|
+
timeoutMs,
|
|
67
|
+
querySize: revalidateTagQuerySize,
|
|
68
|
+
filterKeys,
|
|
69
|
+
resyncIntervalMs: avgResyncIntervalMs +
|
|
70
|
+
avgResyncIntervalMs / 10 +
|
|
71
|
+
Math.random() * (avgResyncIntervalMs / 10),
|
|
72
|
+
});
|
|
73
|
+
this.inMemoryDeduplicationCache = new SyncedMap_1.SyncedMap({
|
|
74
|
+
client: this.client,
|
|
75
|
+
keyPrefix,
|
|
76
|
+
redisKey: 'inMemoryDeduplicationCache',
|
|
77
|
+
database,
|
|
78
|
+
timeoutMs,
|
|
79
|
+
querySize: revalidateTagQuerySize,
|
|
80
|
+
filterKeys,
|
|
81
|
+
customizedSync: {
|
|
82
|
+
withoutRedisHashmap: true,
|
|
83
|
+
withoutSetSync: true,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const redisGet = this.client.get.bind(this.client);
|
|
87
|
+
this.redisDeduplicationHandler = new DeduplicatedRequestHandler_1.DeduplicatedRequestHandler(redisGet, inMemoryCachingTime, this.inMemoryDeduplicationCache);
|
|
88
|
+
this.redisGet = redisGet;
|
|
89
|
+
this.deduplicatedRedisGet =
|
|
90
|
+
this.redisDeduplicationHandler.deduplicatedFunction;
|
|
91
|
+
}
|
|
92
|
+
resetRequestCache(...args) {
|
|
93
|
+
console.warn('WARNING resetRequestCache() was called', args);
|
|
94
|
+
}
|
|
95
|
+
async assertClientIsReady() {
|
|
96
|
+
await Promise.all([
|
|
97
|
+
this.sharedTagsMap.waitUntilReady(),
|
|
98
|
+
this.revalidatedTagsMap.waitUntilReady(),
|
|
99
|
+
]);
|
|
100
|
+
if (!this.client.isReady) {
|
|
101
|
+
throw new Error('Redis client is not ready yet or connection is lost.');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async get(key, ctx) {
|
|
105
|
+
await this.assertClientIsReady();
|
|
106
|
+
const clientGet = this.redisGetDeduplication
|
|
107
|
+
? this.deduplicatedRedisGet(key)
|
|
108
|
+
: this.redisGet;
|
|
109
|
+
const result = await clientGet(getTimeoutRedisCommandOptions(this.timeoutMs), this.keyPrefix + key);
|
|
110
|
+
if (!result) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const cacheValue = JSON.parse(result);
|
|
114
|
+
if (!cacheValue) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (cacheValue.value?.kind === 'FETCH') {
|
|
118
|
+
cacheValue.value.data.body = Buffer.from(cacheValue.value.data.body).toString('base64');
|
|
119
|
+
}
|
|
120
|
+
const combinedTags = new Set([
|
|
121
|
+
...(ctx?.softTags || []),
|
|
122
|
+
...(ctx?.tags || []),
|
|
123
|
+
]);
|
|
124
|
+
if (combinedTags.size === 0) {
|
|
125
|
+
return cacheValue;
|
|
126
|
+
}
|
|
127
|
+
for (const tag of combinedTags) {
|
|
128
|
+
// TODO: check how this revalidatedTagsMap is used or if it can be deleted
|
|
129
|
+
const revalidationTime = this.revalidatedTagsMap.get(tag);
|
|
130
|
+
if (revalidationTime && revalidationTime > cacheValue.lastModified) {
|
|
131
|
+
const redisKey = this.keyPrefix + key;
|
|
132
|
+
// Do not await here as this can happen in the background while we can already serve the cacheValue
|
|
133
|
+
this.client
|
|
134
|
+
.unlink(getTimeoutRedisCommandOptions(this.timeoutMs), redisKey)
|
|
135
|
+
.catch((err) => {
|
|
136
|
+
console.error('Error occurred while unlinking stale data. Retrying now. Error was:', err);
|
|
137
|
+
this.client.unlink(getTimeoutRedisCommandOptions(this.timeoutMs), redisKey);
|
|
138
|
+
})
|
|
139
|
+
.finally(async () => {
|
|
140
|
+
await this.sharedTagsMap.delete(key);
|
|
141
|
+
await this.revalidatedTagsMap.delete(tag);
|
|
142
|
+
});
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return cacheValue;
|
|
147
|
+
}
|
|
148
|
+
async set(key, data, ctx) {
|
|
149
|
+
if (data.kind === 'FETCH') {
|
|
150
|
+
console.time('encoding' + key);
|
|
151
|
+
data.data.body = Buffer.from(data.data.body, 'base64').toString();
|
|
152
|
+
console.timeEnd('encoding' + key);
|
|
153
|
+
}
|
|
154
|
+
await this.assertClientIsReady();
|
|
155
|
+
data.lastModified = Date.now();
|
|
156
|
+
const value = JSON.stringify(data);
|
|
157
|
+
// pre seed data into deduplicated get client. This will reduce redis load by not requesting
|
|
158
|
+
// the same value from redis which was just set.
|
|
159
|
+
if (this.redisGetDeduplication) {
|
|
160
|
+
this.redisDeduplicationHandler.seedRequestReturn(key, value);
|
|
161
|
+
}
|
|
162
|
+
const expireAt = ctx.revalidate &&
|
|
163
|
+
Number.isSafeInteger(ctx.revalidate) &&
|
|
164
|
+
ctx.revalidate > 0
|
|
165
|
+
? this.estimateExpireAge(ctx.revalidate)
|
|
166
|
+
: this.estimateExpireAge(this.defaultStaleAge);
|
|
167
|
+
const options = getTimeoutRedisCommandOptions(this.timeoutMs);
|
|
168
|
+
const setOperation = this.client.set(options, this.keyPrefix + key, value, {
|
|
169
|
+
EX: expireAt,
|
|
170
|
+
});
|
|
171
|
+
let setTagsOperation;
|
|
172
|
+
if (ctx.tags && ctx.tags.length > 0) {
|
|
173
|
+
const currentTags = this.sharedTagsMap.get(key);
|
|
174
|
+
const currentIsSameAsNew = currentTags?.length === ctx.tags.length &&
|
|
175
|
+
currentTags.every((v) => ctx.tags.includes(v)) &&
|
|
176
|
+
ctx.tags.every((v) => currentTags.includes(v));
|
|
177
|
+
if (!currentIsSameAsNew) {
|
|
178
|
+
setTagsOperation = this.sharedTagsMap.set(key, structuredClone(ctx.tags));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
await Promise.all([setOperation, setTagsOperation]);
|
|
182
|
+
}
|
|
183
|
+
async revalidateTag(tagOrTags) {
|
|
184
|
+
const tags = new Set([tagOrTags || []].flat());
|
|
185
|
+
await this.assertClientIsReady();
|
|
186
|
+
// TODO: check how this revalidatedTagsMap is used or if it can be deleted
|
|
187
|
+
for (const tag of tags) {
|
|
188
|
+
if (isImplicitTag(tag)) {
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
await this.revalidatedTagsMap.set(tag, now);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const keysToDelete = [];
|
|
194
|
+
for (const [key, sharedTags] of this.sharedTagsMap.entries()) {
|
|
195
|
+
if (sharedTags.some((tag) => tags.has(tag))) {
|
|
196
|
+
keysToDelete.push(key);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (keysToDelete.length === 0) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const fullRedisKeys = keysToDelete.map((key) => this.keyPrefix + key);
|
|
203
|
+
const options = getTimeoutRedisCommandOptions(this.timeoutMs);
|
|
204
|
+
const deleteKeysOperation = this.client.unlink(options, fullRedisKeys);
|
|
205
|
+
// delete entries from in-memory deduplication cache
|
|
206
|
+
if (this.redisGetDeduplication && this.inMemoryCachingTime > 0) {
|
|
207
|
+
for (const key of keysToDelete) {
|
|
208
|
+
this.inMemoryDeduplicationCache.delete(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const deleteTagsOperation = this.sharedTagsMap.delete(keysToDelete);
|
|
212
|
+
await Promise.all([deleteKeysOperation, deleteTagsOperation]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
exports.default = RedisStringsHandler;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Client } from './RedisStringsHandler';
|
|
2
|
+
type CustomizedSync = {
|
|
3
|
+
withoutRedisHashmap?: boolean;
|
|
4
|
+
withoutSetSync?: boolean;
|
|
5
|
+
};
|
|
6
|
+
type SyncedMapOptions = {
|
|
7
|
+
client: Client;
|
|
8
|
+
keyPrefix: string;
|
|
9
|
+
redisKey: string;
|
|
10
|
+
database: number;
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
querySize: number;
|
|
13
|
+
filterKeys: (key: string) => boolean;
|
|
14
|
+
resyncIntervalMs?: number;
|
|
15
|
+
customizedSync?: CustomizedSync;
|
|
16
|
+
};
|
|
17
|
+
export type SyncMessage<V> = {
|
|
18
|
+
type: 'insert' | 'delete';
|
|
19
|
+
key?: string;
|
|
20
|
+
value?: V;
|
|
21
|
+
keys?: string[];
|
|
22
|
+
};
|
|
23
|
+
export declare class SyncedMap<V> {
|
|
24
|
+
private client;
|
|
25
|
+
private subscriberClient;
|
|
26
|
+
private map;
|
|
27
|
+
private keyPrefix;
|
|
28
|
+
private syncChannel;
|
|
29
|
+
private redisKey;
|
|
30
|
+
private database;
|
|
31
|
+
private timeoutMs;
|
|
32
|
+
private querySize;
|
|
33
|
+
private filterKeys;
|
|
34
|
+
private resyncIntervalMs?;
|
|
35
|
+
private customizedSync?;
|
|
36
|
+
private setupLock;
|
|
37
|
+
private setupLockResolve;
|
|
38
|
+
constructor(options: SyncedMapOptions);
|
|
39
|
+
private setup;
|
|
40
|
+
private initialSync;
|
|
41
|
+
private cleanupKeysNotInRedis;
|
|
42
|
+
private setupPeriodicResync;
|
|
43
|
+
private setupPubSub;
|
|
44
|
+
waitUntilReady(): Promise<void>;
|
|
45
|
+
get(key: string): V | undefined;
|
|
46
|
+
set(key: string, value: V): Promise<void>;
|
|
47
|
+
delete(keys: string[] | string, withoutSyncMessage?: boolean): Promise<void>;
|
|
48
|
+
has(key: string): boolean;
|
|
49
|
+
entries(): IterableIterator<[string, V]>;
|
|
50
|
+
}
|
|
51
|
+
export {};
|