From ea0d44f4dff75310bd3370bfe04528db6bec48a7 Mon Sep 17 00:00:00 2001 From: Zisa <git@zisa.dev> Date: Thu, 10 Oct 2024 13:20:04 -0300 Subject: [PATCH] Replace the old redis cache implementation (#858) I created a new version of the Redis cache engine. Currently it supports the put, match and delete methods. The connection and methods will not block the load of the pages. It was made to also support reconnection on failure; and even during a failure the website will keep running until the connection gets healthy again. When an error occurs, the connection will be halted on purpose. Just like a open circuit breaker. The idea is to not fall into a reconnection loop. Once the website tries to cache again, the engine will try to reconnect. Co-authored-by: Marcos Candeia <marrcooos@gmail.com> --- runtime/caches/mod.ts | 10 +++ runtime/caches/redis.ts | 139 ++++++++++++++++++++++++++++++++++++++++ runtime/caches/utils.ts | 2 +- 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 runtime/caches/redis.ts diff --git a/runtime/caches/mod.ts b/runtime/caches/mod.ts index 32c0ba00e..594aa35da 100644 --- a/runtime/caches/mod.ts +++ b/runtime/caches/mod.ts @@ -4,6 +4,11 @@ import { isFileSystemAvailable } from "./fileSystem.ts"; import { caches as headersCache } from "./headerscache.ts"; +import { + caches as redisCache, + isAvailable as isRedisCacheAvailable, +} from "./redis.ts"; + import { createTieredCache } from "./tiered.ts"; import { caches as lruCache } from "./lrucache.ts"; @@ -24,6 +29,7 @@ export interface CacheStorageOption { export type CacheEngine = | "CACHE_API" + | "REDIS" | "FILE_SYSTEM"; export const cacheImplByEngine: Record<CacheEngine, CacheStorageOption> = { @@ -35,6 +41,10 @@ export const cacheImplByEngine: Record<CacheEngine, CacheStorageOption> = { implementation: headersCache(lruCache(fileSystem)), isAvailable: isFileSystemAvailable, }, + REDIS: { + implementation: redisCache, + isAvailable: isRedisCacheAvailable, + }, }; for (const [engine, cache] of Object.entries(cacheImplByEngine)) { diff --git a/runtime/caches/redis.ts b/runtime/caches/redis.ts new file mode 100644 index 000000000..6be2576c0 --- /dev/null +++ b/runtime/caches/redis.ts @@ -0,0 +1,139 @@ +import { + assertCanBeCached, + assertNoOptions, + baseCache, + NOT_IMPLEMENTED, + withCacheNamespace, +} from "./utils.ts"; +import { + createClient, + type RedisClientType, + type RedisFunctions, + type RedisModules, + type RedisScripts, +} from "npm:@redis/client@^1.6.0"; + +const CONNECTION_TIMEOUT = 500; +const RECONNECTION_TIMEOUT = 5000; + +type RedisConnection = RedisClientType< + RedisModules, + RedisFunctions, + RedisScripts +>; + +let redis: null | RedisConnection = null; + +export const isAvailable = Deno.env.has("LOADER_CACHE_REDIS_URL"); + +function connect(): void { + if (!isAvailable) { + return; + } + + redis ??= createClient({ + url: Deno.env.get("LOADER_CACHE_REDIS_URL"), + }); + + redis.on("error", () => { + if (redis?.isOpen) { + redis?.disconnect(); + } + + wait(RECONNECTION_TIMEOUT).then(() => redis?.connect()); + }); + + redis.connect(); +} + +async function serialize(response: Response): Promise<string> { + const body = await response.text(); + + return JSON.stringify({ + body: body, + headers: response.headers, + status: response.status, + }); +} + +function deserialize(raw: string): Response { + const { body, headers, status } = JSON.parse(raw); + return new Response(body, { headers, status }); +} + +function wait(ms: number) { + return new Promise((run) => setTimeout(run, ms)); +} + +export const caches: CacheStorage = { + open: async (namespace: string): Promise<Cache> => { + await Promise.race([connect(), wait(CONNECTION_TIMEOUT)]); + + return Promise.resolve({ + ...baseCache, + delete: async ( + request: RequestInfo | URL, + _?: CacheQueryOptions, + ): Promise<boolean> => { + const generateKey = withCacheNamespace(namespace); + + return generateKey(request) + .then((cacheKey: string) => { + redis?.del(cacheKey); + + return true; + }) + .catch(() => false); + }, + match: async ( + request: RequestInfo | URL, + options?: CacheQueryOptions, + ): Promise<Response | undefined> => { + assertNoOptions(options); + + const generateKey = withCacheNamespace(namespace); + + return generateKey(request) + .then((cacheKey: string) => redis?.get(cacheKey) ?? null) + .then((result: string | null) => { + if (!result) { + return undefined; + } + + return deserialize(result); + }) + .catch(() => undefined); + }, + put: async ( + request: RequestInfo | URL, + response: Response, + ): Promise<void> => { + const req = new Request(request); + assertCanBeCached(req, response); + + if (!response.body) { + return; + } + + const generateKey = withCacheNamespace(namespace); + const cacheKey = await generateKey(request); + + serialize(response) + .then((data) => { + const expirationTimestamp = Date.parse( + response.headers.get("expires") ?? "", + ); + + const ttl = expirationTimestamp - Date.now(); + + return redis?.set(cacheKey, data, { PX: ttl }); + }) + .catch(() => {}); + }, + }); + }, + delete: NOT_IMPLEMENTED, + has: NOT_IMPLEMENTED, + keys: NOT_IMPLEMENTED, + match: NOT_IMPLEMENTED, +}; diff --git a/runtime/caches/utils.ts b/runtime/caches/utils.ts index b5fd6bb77..4daa1cba2 100644 --- a/runtime/caches/utils.ts +++ b/runtime/caches/utils.ts @@ -9,7 +9,7 @@ export const sha1 = async (text: string) => { return hex; }; -const NOT_IMPLEMENTED = () => { +export const NOT_IMPLEMENTED = () => { throw new Error("Not Implemented"); };