From 68e0621c637fda2918904fcb53137b73b79f9000 Mon Sep 17 00:00:00 2001 From: Itamar Rocha Filho Date: Thu, 26 Sep 2024 14:27:45 -0300 Subject: [PATCH] Add support to deno deploy cache api implementation (#741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support to deno deploy cache api implementation Documentation for deno's Edge Cache are available at https://docs.deno.com/deploy/manual/edge-cache/ --------- Co-authored-by: Matheus Gaudencio do RĂªgo --- README.md | 13 +- blocks/loader.ts | 25 ++- runtime/caches/common.ts | 43 +--- runtime/caches/denoKV.ts | 358 --------------------------------- runtime/caches/fileSystem.ts | 345 +------------------------------ runtime/caches/headerscache.ts | 79 ++++++++ runtime/caches/lrucache.ts | 96 +++++++++ runtime/caches/mod.test.ts | 139 +++++++++++++ runtime/caches/mod.ts | 43 +--- runtime/caches/redis.ts | 206 ------------------- runtime/caches/s3.ts | 307 ---------------------------- runtime/caches/utils.ts | 101 ++++++++++ 12 files changed, 456 insertions(+), 1299 deletions(-) delete mode 100644 runtime/caches/denoKV.ts create mode 100644 runtime/caches/headerscache.ts create mode 100644 runtime/caches/lrucache.ts create mode 100644 runtime/caches/mod.test.ts delete mode 100644 runtime/caches/redis.ts delete mode 100644 runtime/caches/s3.ts create mode 100644 runtime/caches/utils.ts diff --git a/README.md b/README.md index 76b1bd7a8..50f861541 100644 --- a/README.md +++ b/README.md @@ -171,17 +171,12 @@ Here is a table with the integrations that we have built and the statuses of the ## Cache env vars (WIP) | Environment Variable | Description | Example Value | |-----------------------------------|---------------------------------------------------------|--------------------------------------------------------| -| `CACHE_UPLOAD_BUCKET` | The AWS S3 bucket name for cache uploads | `BUCKET-NAME` | -| `CACHE_AWS_REGION` | AWS region where the cache bucket is located | `sa-east-1` | -| `CACHE_AWS_ACCESS_KEY_ID` | AWS access key ID for authentication | `` | -| `CACHE_AWS_SECRET_ACCESS_KEY` | AWS secret access key for authentication | `` | | `ENABLE_LOADER_CACHE` | Flag to enable or disable the loader cache | `true` | | `LOADER_CACHE_START_TRESHOLD` | Cache start threshold | `0` | -| `WEB_CACHE_ENGINE` | Defines the cache engine(s) to use | `"FILE_SYSTEM,S3"` | -| `FILE_SYSTEM_CACHE_DIRECTORY` | Directory path for file system cache | `` | -| `MAX_CACHE_SIZE` | Maximum size of the file system cache (in bytes) | `1073741824` (1 GB) | -| `TTL_AUTOPURGE` | Flag to automatically delete expired items from the file system cache (cpu intensive) | `false` | -| `TTL_RESOLUTION` | Time interval to check for expired items in the file system cache (in milliseconds) | `30000` (30 seconds) | +| `WEB_CACHE_ENGINE` | Defines the cache engine(s) to use | `"FILE_SYSTEM,CACHE_API"` | +| `CACHE_MAX_SIZE` | Maximum size of the file system cache (in bytes) | `1073741824` (1 GB) | +| `CACHE_TTL_AUTOPURGE` | Flag to automatically delete expired items from the file system cache (cpu intensive) | `false` | +| `CACHE_TTL_RESOLUTION` | Time interval to check for expired items in the file system cache (in milliseconds) | `30000` (30 seconds) | | `CACHE_MAX_AGE_S` | Time for cache to become stale | `60` (60 seconds) | diff --git a/blocks/loader.ts b/blocks/loader.ts index 1fb2272c1..42f360dd7 100644 --- a/blocks/loader.ts +++ b/blocks/loader.ts @@ -118,9 +118,9 @@ export const wrapCaughtErrors = async < }; export const LOADER_CACHE_START_TRESHOLD = - Deno.env.get("LOADER_CACHE_START_TRESHOLD") ?? 5; + Deno.env.get("LOADER_CACHE_START_TRESHOLD") ?? 0; -export const LOADER_CACHE_SIZE = Deno.env.get("LOADER_CACHE_SIZE") ?? 1_024; +export const LOADER_CACHE_SIZE = Deno.env.get("LOADER_CACHE_SIZE") ?? 1_024_000; const stats = { cache: meter.createCounter("loader_cache", { @@ -291,13 +291,24 @@ const wrapLoader = ( const callHandlerAndCache = async () => { const json = await handler(props, req, ctx); + const jsonStringEncoded = new TextEncoder().encode( + JSON.stringify(json), + ); + + const headers: { [key: string]: string } = { + expires: new Date(Date.now() + (MAX_AGE_S * 1e3)) + .toUTCString(), + "Content-Type": "application/json", + }; + + if (jsonStringEncoded && jsonStringEncoded.length > 0) { + headers["Content-Length"] = "" + jsonStringEncoded.length; + } + cache.put( request, - new Response(JSON.stringify(json), { - headers: { - "expires": new Date(Date.now() + (MAX_AGE_S * 1e3)) - .toUTCString(), - }, + new Response(jsonStringEncoded, { + headers: headers, }), ).catch((error) => logger.error(`loader error ${error}`)); diff --git a/runtime/caches/common.ts b/runtime/caches/common.ts index 42604c446..39b3b7bc5 100644 --- a/runtime/caches/common.ts +++ b/runtime/caches/common.ts @@ -1,47 +1,6 @@ import { ValueType } from "../../deps.ts"; import { tracer } from "../../observability/otel/config.ts"; import { meter } from "../../observability/otel/metrics.ts"; -import { sha1 } from "../utils.ts"; - -export const assertNoOptions = ( - { ignoreMethod, ignoreSearch, ignoreVary }: CacheQueryOptions = {}, -) => { - if (ignoreMethod || ignoreSearch || ignoreVary) { - throw new Error("Not Implemented"); - } -}; - -export const requestURL = (request: RequestInfo | URL): string => { - return typeof request === "string" - ? request - : request instanceof URL - ? request.href - : request.url; -}; - -export const withCacheNamespace = - (cacheName: string) => (request: RequestInfo | URL): Promise => { - return requestURLSHA1(request).then((key) => `${key}${cacheName}`); - }; - -export const requestURLSHA1 = (request: RequestInfo | URL): Promise => { - return sha1(requestURL(request)); -}; - -export const assertCanBeCached = (req: Request, response: Response) => { - if (!/^http(s?):\/\//.test(req.url)) { - throw new TypeError( - "Request url protocol must be 'http:' or 'https:'", - ); - } - if (req.method !== "GET") { - throw new TypeError("Request method must be GET"); - } - - if (response.status === 206) { - throw new TypeError("Response status must not be 206"); - } -}; export interface CacheMetrics { engine: string; @@ -63,6 +22,8 @@ export const withInstrumentation = ( const cacheImpl = await cache.open(cacheName); return { ...cacheImpl, + delete: cacheImpl.delete.bind(cacheImpl), + put: cacheImpl.put.bind(cacheImpl), match: async (req, opts) => { const span = tracer.startSpan("cache-match", { attributes: { engine }, diff --git a/runtime/caches/denoKV.ts b/runtime/caches/denoKV.ts deleted file mode 100644 index bd8e9fc2d..000000000 --- a/runtime/caches/denoKV.ts +++ /dev/null @@ -1,358 +0,0 @@ -/** - * WebCache API powered by Deno.KV - * - * This creates two indices on KV, one for metadata and another for body chunks. - * - * 1. ['CACHES', cacheName, 'metas', key] // response.status, response.headers, response.etag - * 2. ['CACHES', cacheName, 'chunks', etag, chunkId] // response.body for response.etag - * - * `key` is determined after the orignal request. Etag is an uuid representing the - * response's body version. ChunkId is the chunk number after splitting the body response's - * into 64Kb chunks. - * - * How it works: - * - * getMedata (request): - * key <- sha1(request.url + request.headers) - * - * return key from 'metas' index on Deno.KV - * - * match (request, response): - * metadata <- getMetadata(request) - * - * if metadata not exists: - * return - * - * etag <- metadata.etag - * body <- create stream from etag chunks - * - * return Response(body, metadata) - * - * put (request, response): - * oldMeta <- getMetadata(request) - * newMeta <- { status: response.status, headers: response.headers, etag: new UUID() } - * - * save chunks for response with newMetag.etag on chunks index - * res <- atomically replace oldMeta with newMeta - * - * if (res.ok) expire oldMeta chunks - * else expire newMeta chunks - */ -import { - compress, - decompress, - init as initZstd, -} from "npm:@bokuweb/zstd-wasm@0.0.20"; -import { ValueType } from "../../deps.ts"; -import { meter } from "../../observability/otel/metrics.ts"; -import { - assertCanBeCached, - assertNoOptions, - requestURLSHA1, -} from "./common.ts"; - -interface Metadata { - body: { - etag: string; // body version - chunks: number; // number of chunks in body - zstd: boolean; - }; - status: number; - headers: [string, string][]; -} - -// Create an UpDownSumObserver to track the sum of buffer sizes -const bufferSizeSumObserver = meter.createUpDownCounter("buffer_size_sum", { - description: "Sum of buffer sizes", - unit: "1", - valueType: ValueType.INT, -}); - -const compressDuration = meter.createHistogram("zstd_compress_duration", { - description: "compress duration", - unit: "ms", - valueType: ValueType.DOUBLE, -}); - -const NAMESPACE = "CACHES"; -const SMALL_EXPIRE_MS = 1_000 * 10; // 10seconds -const LARGE_EXPIRE_MS = 1_000 * 3600 * 24; // 1day - -/** KV has a max of 64Kb per chunk */ -const MAX_CHUNK_SIZE = 64512; -const MAX_CHUNKS_BATCH_SIZE = 10; -const MAX_UNCOMPRESSED_SIZE = MAX_CHUNK_SIZE * MAX_CHUNKS_BATCH_SIZE; - -const zstdPromise = initZstd(); - -export const caches: CacheStorage = { - delete: async (cacheName: string): Promise => { - // @ts-ignore: Deno type definitions are missing openKv - const kv = await Deno.openKv(); - - for await ( - const entry of kv.list({ prefix: [NAMESPACE, cacheName] }) - ) { - await kv.delete(entry.key); - } - - return true; - }, - has: (_cacheName: string): Promise => { - throw new Error("Not Implemented"); - }, - keys: (): Promise => { - throw new Error("Not Implemented"); - }, - match: ( - _request: URL | RequestInfo, - _options?: MultiCacheQueryOptions | undefined, - ): Promise => { - throw new Error("Not Implemented"); - }, - open: async (cacheName: string): Promise => { - await zstdPromise; - // @ts-ignore: Deno type definitions are missing openKv - const kv = await Deno.openKv(); - - const keyForMetadata = (sha?: string) => { - const key = [NAMESPACE, cacheName, "metas"]; - - if (typeof sha === "string") { - key.push(sha); - } - - return key; - }; - - const keyForBodyChunk = ( - etag?: string, - chunk?: number, - ) => { - const key: Array = [NAMESPACE, cacheName, "chunks"]; - - if (typeof etag === "string") { - key.push(etag); - - if (typeof chunk === "number") { - key.push(chunk); - } - } - - return key; - }; - - const removeBodyChunks = async (meta: Metadata) => { - const { chunks, etag } = meta.body ?? {}; - - if (!chunks || !etag) return; - - let ok = true; - for (let it = 0; it < chunks; it++) { - const key = keyForBodyChunk(etag, it); - - const chunk = await kv.get(key); - - if (!chunk.value) continue; - - const res = await kv - .atomic() - .check(chunk) - .set(key, chunk, { expireIn: SMALL_EXPIRE_MS }) - .commit(); - - ok &&= res.ok; - } - - if (!ok) { - throw new Error( - `Error while reducing expire rate for chunk ${keyForBodyChunk(etag)}`, - ); - } - }; - - const remove = async (key: string[]) => { - const metadata = await kv.get(key); - await kv.delete(key); - - if (metadata.value) { - await removeBodyChunks(metadata.value); - } - }; - - const keyForRequest = async (request: RequestInfo | URL) => { - return keyForMetadata(await requestURLSHA1(request)); - }; - - return { - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/add) */ - add: (_request: RequestInfo | URL): Promise => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/addAll) */ - addAll: (_requests: RequestInfo[]): Promise => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/delete) */ - delete: async ( - request: RequestInfo | URL, - options?: CacheQueryOptions, - ): Promise => { - assertNoOptions(options); - - const key = await keyForRequest(request); - await remove(key); - - return true; - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/keys) */ - keys: ( - _request?: RequestInfo | URL, - _options?: CacheQueryOptions, - ): Promise> => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/match) */ - match: async ( - request: RequestInfo | URL, - options?: CacheQueryOptions, - ): Promise => { - assertNoOptions(options); - - const key = await keyForRequest(request); - - const res = await kv.get(key, { - consistency: "eventual", - }); - const { value: metadata } = res; - - if (!metadata) return; - - const { body } = metadata; - - if (body.chunks === 0) { - return new Response(null, metadata); - } - // @ts-ignore: Deno type definitions are missing openKv - const many: Promise[]>[] = []; - for (let i = 0; i < body.chunks; i += MAX_CHUNKS_BATCH_SIZE) { - const batch = []; - for (let j = 0; j < MAX_CHUNKS_BATCH_SIZE; j++) { - batch.push(keyForBodyChunk(body.etag, i + j)); - } - - many.push( - kv.getMany(batch, { consistency: "eventual" }), - ); - } - - let length = 0; - for (const chunks of many) { - for (const chunk of await chunks) { - length += chunk.value?.length ?? 0; - } - } - - const result = new Uint8Array(length); - - let bytes = 0; - for (const chunks of many) { - for (const chunk of await chunks) { - if (!chunk.value) continue; - - result.set(chunk.value, bytes); - bytes += chunk.value.length ?? 0; - } - } - - return new Response( - body.zstd ? decompress(result) : result, - metadata, - ); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/matchAll) */ - matchAll: ( - _request?: RequestInfo | URL, - _options?: CacheQueryOptions, - ): Promise> => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/put) */ - put: async ( - request: RequestInfo | URL, - response: Response, - ): Promise => { - const req = new Request(request); - - assertCanBeCached(req, response); - - const metaKey = await keyForRequest(req); - const oldMeta = await kv.get(metaKey); - - const [buffer, zstd] = await response.arrayBuffer() - .then((buffer) => new Uint8Array(buffer)) - .then((buffer) => { - bufferSizeSumObserver.add(buffer.length); - return buffer; - }) - .then((buffer) => { - if (buffer.length > MAX_UNCOMPRESSED_SIZE) { - const start = performance.now(); - const compressed = compress(buffer, 4); - compressDuration.record(performance.now() - start, { - bufferSize: buffer.length, - compressedSize: compressed.length, - }); - return [compressed, true] as const; - } - return [buffer, false] as const; - }); - - // Orphaned chunks to remove after metadata change - let orphaned = oldMeta.value; - const chunks = Math.ceil(buffer.length / MAX_CHUNK_SIZE); - const newMeta: Metadata = { - status: response.status, - headers: [...response.headers.entries()], - body: { etag: crypto.randomUUID(), chunks, zstd }, - }; - - try { - // Save each file chunk - // Note that chunks expiration should be higher than metadata - // to avoid reading a file with missing chunks - for (let it = 0; it < chunks; it++) { - const res = await kv.set( - keyForBodyChunk(newMeta.body.etag, it), - buffer.slice( - it * MAX_CHUNK_SIZE, - (it + 1) * MAX_CHUNK_SIZE, - ), - { expireIn: LARGE_EXPIRE_MS + SMALL_EXPIRE_MS }, - ); - - if (!res.ok) { - throw new Error("Error while saving chunk to KV"); - } - } - - // Save file metadata - const res = await kv.set(metaKey, newMeta, { - expireIn: LARGE_EXPIRE_MS, - }); - - if (!res.ok) { - throw new Error("Could not set our metadata"); - } - } catch (error) { - orphaned = newMeta; - console.error(error); - } - - if (orphaned) { - await removeBodyChunks(orphaned); - } - }, - }; - }, -}; diff --git a/runtime/caches/fileSystem.ts b/runtime/caches/fileSystem.ts index 16f97084e..94e1debf7 100644 --- a/runtime/caches/fileSystem.ts +++ b/runtime/caches/fileSystem.ts @@ -1,344 +1,11 @@ -import { existsSync } from "@std/fs"; -import { LRUCache } from "npm:lru-cache@10.2.0"; -import { ValueType } from "../../deps.ts"; -import { logger, tracer } from "../../observability/otel/config.ts"; -import { meter } from "../../observability/otel/metrics.ts"; -import { numToUint8Array, uint8ArrayToNum } from "../utils.ts"; -import { - assertCanBeCached, - assertNoOptions, - withCacheNamespace, -} from "./common.ts"; +const FILE_SYSTEM_CACHE_DIRECTORY = "/tmp"; -const FILE_SYSTEM_CACHE_DIRECTORY = - Deno.env.get("FILE_SYSTEM_CACHE_DIRECTORY") ?? undefined; - -const MAX_CACHE_SIZE = parseInt(Deno.env.get("MAX_CACHE_SIZE") ?? "1073741824"); // 1 GB max size of cache -const TTL_AUTOPURGE = Deno.env.get("TTL_AUTOPURGE") !== "false"; // automatically delete expired items -const TTL_RESOLUTION = parseInt(Deno.env.get("TTL_RESOLUTION") ?? "30000"); // check for expired items every 30 seconds - -const downloadDuration = meter.createHistogram( - "file_system_cache_download_duration", - { - description: "file system cache download duration", - unit: "ms", - valueType: ValueType.DOUBLE, - }, -); - -const bufferSizeSumObserver = meter.createUpDownCounter("buffer_size_sum", { - description: "Sum of buffer sizes", - unit: "1", - valueType: ValueType.INT, -}); - -const failedToDisposeFromCache = meter.createCounter( - "failed_to_delete_from_fs_cache", - { - description: "Counter of failed attempts to delete from file system cache", - unit: "1", - valueType: ValueType.INT, - }, -); - -const cacheOptions = { - maxSize: MAX_CACHE_SIZE, - ttlAutopurge: TTL_AUTOPURGE, - ttlResolution: TTL_RESOLUTION, - sizeCalculation: (value: Uint8Array) => { - return uint8ArrayToNum(value); // return the length of the array - }, - dispose: async (_value: Uint8Array, key: string) => { - await Deno.remove(`${FILE_SYSTEM_CACHE_DIRECTORY}/${key}`).catch((err) => - failedToDisposeFromCache.add(1, { err }) - ); - }, -}; - -// Function to convert headers object to a Uint8Array -function headersToUint8Array(headers: [string, string][]) { - const headersStr = JSON.stringify(headers); - return new TextEncoder().encode(headersStr); -} - -// Function to combine the body and headers into a single buffer -function generateCombinedBuffer(body: Uint8Array, headers: Uint8Array) { - // This prepends the header length to the combined buffer. As it has 4 bytes in size, - // it can store up to 2^32 - 1 bytes of headers (4GB). This should be enough for all deco use cases. - const headerLength = new Uint8Array(new Uint32Array([headers.length]).buffer); - - // Concatenate length, headers, and body into one Uint8Array - const combinedBuffer = new Uint8Array( - headerLength.length + headers.length + body.length, - ); - combinedBuffer.set(headerLength, 0); - combinedBuffer.set(headers, headerLength.length); - combinedBuffer.set(body, headerLength.length + headers.length); - return combinedBuffer; -} - -// Function to extract the headers and body from a combined buffer -function extractCombinedBuffer(combinedBuffer: Uint8Array) { - // Extract the header length from the combined buffer - const headerLengthArray = combinedBuffer.slice(0, 4); - const headerLength = new Uint32Array(headerLengthArray.buffer)[0]; - - // Extract the headers and body from the combined buffer - const headers = combinedBuffer.slice(4, 4 + headerLength); - const body = combinedBuffer.slice(4 + headerLength); - return { headers, body }; -} - -function getIterableHeaders(headers: Uint8Array) { - const headersStr = new TextDecoder().decode(headers); - - // Directly parse the string as an array of [key, value] pairs - const headerPairs: [string, string][] = JSON.parse(headersStr); - - // Filter out any pairs with empty key or value - const filteredHeaders = headerPairs.filter(([key, value]) => - key !== "" && value !== "" - ); - return filteredHeaders; -} - -const fileCache = new LRUCache(cacheOptions); - -function createFileSystemCache(): CacheStorage { - let isCacheInitialized = false; - async function assertCacheDirectory() { - try { - if ( - FILE_SYSTEM_CACHE_DIRECTORY && !existsSync(FILE_SYSTEM_CACHE_DIRECTORY) - ) { - await Deno.mkdirSync(FILE_SYSTEM_CACHE_DIRECTORY, { recursive: true }); - } - isCacheInitialized = true; - } catch (err) { - console.error("Unable to initialize file system cache directory", err); - } - } - - async function putFile( - key: string, - responseArray: Uint8Array, - expires: string, - ) { - if (!isCacheInitialized) { - await assertCacheDirectory(); - } - const filePath = `${FILE_SYSTEM_CACHE_DIRECTORY}/${key}`; - - const expirationTimestamp = Date.parse(expires); // Convert expires string to a number representing the expiration timestamp - const ttl = expirationTimestamp - Date.now(); // Calculate the time to live (ttl) by subtracting the current timestamp from the expiration timestamp - - fileCache.set(key, numToUint8Array(responseArray.length), { - ttl: ttl, // Set the ttl of the file added - }); // Add to cache, which may trigger disposal of old item - await Deno.writeFile(filePath, responseArray); - return; - } - - async function getFile(key: string) { - if (!isCacheInitialized) { - await assertCacheDirectory(); - } - try { - if (fileCache.has(key)) { - // Update the access time in the cache - fileCache.get(key); - } - const filePath = `${FILE_SYSTEM_CACHE_DIRECTORY}/${key}`; - const fileContent = await Deno.readFile(filePath); - return fileContent; - } catch (err) { - // Error code different for file/dir not found - // The file won't be found in cases where it's not cached - if (err.code !== "ENOENT") { - logger.error(`error when reading from file system, ${err}`); - } - return null; - } - } - - async function deleteFile(key: string) { - if (!isCacheInitialized) { - await assertCacheDirectory(); - } - try { - const filePath = `${FILE_SYSTEM_CACHE_DIRECTORY}/${key}`; - await Deno.remove(filePath); - return true; - } catch (err) { - logger.error(`error when deleting from file system, ${err}`); - return false; - } - } - - const caches: CacheStorage = { - delete: (_cacheName: string): Promise => { - throw new Error("Not Implemented"); - }, - has: (_cacheName: string): Promise => { - throw new Error("Not Implemented"); - }, - keys: (): Promise => { - throw new Error("Not Implemented"); - }, - match: ( - _request: URL | RequestInfo, - _options?: MultiCacheQueryOptions | undefined, - ): Promise => { - throw new Error("Not Implemented"); - }, - open: (cacheName: string): Promise => { - const requestURLSHA1 = withCacheNamespace(cacheName); - return Promise.resolve({ - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/add) */ - add: (_request: RequestInfo | URL): Promise => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/addAll) */ - addAll: (_requests: RequestInfo[]): Promise => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/delete) */ - delete: async ( - request: RequestInfo | URL, - options?: CacheQueryOptions, - ): Promise => { - assertNoOptions(options); - - const deleteResponse = await deleteFile( - await requestURLSHA1(request), - ); - return deleteResponse; - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/keys) */ - keys: ( - _request?: RequestInfo | URL, - _options?: CacheQueryOptions, - ): Promise> => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/match) */ - match: async ( - request: RequestInfo | URL, - options?: CacheQueryOptions, - ): Promise => { - assertNoOptions(options); - const cacheKey = await requestURLSHA1(request); - const span = tracer.startSpan("file-system-get", { - attributes: { - cacheKey, - }, - }); - try { - const startTime = performance.now(); - const data = await getFile(cacheKey); - const downloadDurationTime = performance.now() - startTime; - - span.addEvent("file-system-get-data"); - - if (data === null) { - return undefined; - } - - downloadDuration.record(downloadDurationTime, { - bufferSize: data.length, - }); - - const { headers, body } = extractCombinedBuffer(data); - const iterableHeaders = getIterableHeaders(headers); - const responseHeaders = new Headers(iterableHeaders); - return new Response( - body, - { headers: responseHeaders }, - ); - } catch (err) { - throw err; - } finally { - span.end(); - } - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/matchAll) */ - matchAll: ( - _request?: RequestInfo | URL, - _options?: CacheQueryOptions, - ): Promise> => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/put) */ - put: async ( - request: RequestInfo | URL, - response: Response, - ): Promise => { - const req = new Request(request); - assertCanBeCached(req, response); - - if (!response.body) { - return; - } - - const cacheKey = await requestURLSHA1(request); - const bodyBuffer = await response.arrayBuffer() - .then((buffer) => new Uint8Array(buffer)) - .then((buffer) => { - bufferSizeSumObserver.add(buffer.length); - return buffer; - }); - const headersBuffer = headersToUint8Array([ - ...response.headers.entries(), - ]); - const buffer = generateCombinedBuffer(bodyBuffer, headersBuffer); - - const span = tracer.startSpan("file-system-put", { - attributes: { - cacheKey, - }, - }); - - try { - try { - const setSpan = tracer.startSpan("file-system-set", { - attributes: { cacheKey }, - }); - await putFile( - cacheKey, - buffer, - response.headers.get("expires") ?? "", - ).catch( - (err) => { - console.error("file system error", err); - setSpan.recordException(err); - }, - ).finally(() => { - setSpan.end(); - }); // do not await for setting cache - } catch (error) { - logger.error(`error saving to file system ${error?.message}`); - } - } catch (err) { - span.recordException(err); - throw err; - } finally { - span.end(); - } - }, - }); - }, - }; - - return caches; -} - -const hasWritePerm = async (): Promise => { +const hasWritePerm = async (fsDir: string): Promise => { return await Deno.permissions.query( - { name: "write", path: FILE_SYSTEM_CACHE_DIRECTORY } as const, + { name: "write", path: fsDir } as const, ).then((status) => status.state === "granted"); }; -export const isFileSystemAvailable = await hasWritePerm() && - FILE_SYSTEM_CACHE_DIRECTORY !== undefined; - -export const caches = createFileSystemCache(); +export const isFileSystemAvailable = + FILE_SYSTEM_CACHE_DIRECTORY !== undefined && + await hasWritePerm(FILE_SYSTEM_CACHE_DIRECTORY); diff --git a/runtime/caches/headerscache.ts b/runtime/caches/headerscache.ts new file mode 100644 index 000000000..2cc925c22 --- /dev/null +++ b/runtime/caches/headerscache.ts @@ -0,0 +1,79 @@ +import { + assertCanBeCached, + assertNoOptions, + baseCache, + createBaseCacheStorage, +} from "./utils.ts"; + +const CACHE_MAX_AGE_S = parseInt(Deno.env.get("CACHE_MAX_AGE_S") ?? "60"); // 60 seconds + +function createLruCacheStorage(cacheStorageInner: CacheStorage): CacheStorage { + const caches = createBaseCacheStorage( + cacheStorageInner, + (_cacheName, cacheInner, requestURLSHA1) => { + return Promise.resolve({ + ...baseCache, + delete: async ( + request: RequestInfo | URL, + options?: CacheQueryOptions, + ): Promise => { + const cacheKey = await requestURLSHA1(request); + return cacheInner.delete(cacheKey, options); + }, + match: async ( + request: RequestInfo | URL, + options?: CacheQueryOptions, + ): Promise => { + assertNoOptions(options); + const cacheKey = await requestURLSHA1(request); + return cacheInner.match(cacheKey); + }, + put: async ( + request: RequestInfo | URL, + response: Response, + ): Promise => { + const req = new Request(request); + assertCanBeCached(req, response); + + if (!response.body) { + return; + } + const cacheKey = await requestURLSHA1(request); + const length = response.headers.get("Content-Length"); + if (length) { + if (length == "0") { + return; + } else { + return cacheInner.put( + cacheKey, + new Response(response.body, { + headers: new Headers({ + ...response.headers, + expires: new Date(Date.now() + (CACHE_MAX_AGE_S * 1e3)) + .toUTCString(), + "Content-Length": length, + }), + }), + ); + } + } + const body = await response.arrayBuffer(); + return cacheInner.put( + cacheKey, + new Response(body, { + headers: new Headers({ + ...response.headers, + expires: new Date(Date.now() + (CACHE_MAX_AGE_S * 1e3)) + .toUTCString(), + "Content-Length": `${body.byteLength}`, + }), + }), + ); + }, + }); + }, + ); + return caches; +} + +export const caches = (cache: CacheStorage) => createLruCacheStorage(cache); diff --git a/runtime/caches/lrucache.ts b/runtime/caches/lrucache.ts new file mode 100644 index 000000000..f5ccd5562 --- /dev/null +++ b/runtime/caches/lrucache.ts @@ -0,0 +1,96 @@ +import { LRUCache } from "npm:lru-cache@10.2.0"; +import { + assertCanBeCached, + assertNoOptions, + baseCache, + createBaseCacheStorage, +} from "./utils.ts"; + +const CACHE_MAX_SIZE = parseInt( + Deno.env.get("CACHE_MAX_SIZE") ?? "1073824", +); // 1 GB max size of cache +const CACHE_TTL_AUTOPURGE = Deno.env.get("CACHE_TTL_AUTOPURGE") !== "false"; // automatically delete expired items +const CACHE_ALLOW_STALE = Deno.env.get("CACHE_ALLOW_STALE") !== "false"; // automatically allow stale +const CACHE_TTL_RESOLUTION = parseInt( + Deno.env.get("CACHE_TTL_RESOLUTION") ?? "30000", +); // check for expired items every 30 seconds + +const cacheOptions = (cache: Cache) => ( + { + maxSize: CACHE_MAX_SIZE, + ttlAutopurge: CACHE_TTL_AUTOPURGE, + ttlResolution: CACHE_TTL_RESOLUTION, + allowStale: CACHE_ALLOW_STALE, + dispose: async (_value: Uint8Array, key: string) => { + await cache.delete(key); + }, + } +); + +function createLruCacheStorage(cacheStorageInner: CacheStorage): CacheStorage { + const caches = createBaseCacheStorage( + cacheStorageInner, + (_cacheName, cacheInner, requestURLSHA1) => { + const fileCache = new LRUCache(cacheOptions(cacheInner)); + return Promise.resolve({ + ...baseCache, + delete: async ( + request: RequestInfo | URL, + options?: CacheQueryOptions, + ): Promise => { + const cacheKey = await requestURLSHA1(request); + cacheInner.delete(cacheKey, options); + return fileCache.delete(cacheKey); + }, + match: async ( + request: RequestInfo | URL, + options?: CacheQueryOptions, + ): Promise => { + assertNoOptions(options); + const cacheKey = await requestURLSHA1(request); + if (fileCache.has(cacheKey)) { + fileCache.get(cacheKey); + const result = cacheInner.match(cacheKey); + if (!result) { + // innercache miss + fileCache.delete(cacheKey); + } + return result; + } + return undefined; + }, + put: async ( + request: RequestInfo | URL, + response: Response, + ): Promise => { + const req = new Request(request); + assertCanBeCached(req, response); + + if (!response.body) { + return; + } + + const expirationTimestamp = Date.parse( + response.headers.get("expires") ?? "", + ); + + const ttl = expirationTimestamp - Date.now(); + + const cacheKey = await requestURLSHA1(request); + const length = response.headers.get("Content-Length"); + if (!length || length == "0") { + return; + } + fileCache.set(cacheKey, new Uint8Array(), { + size: parseInt(length), + ttl, + }); + return cacheInner.put(cacheKey, response); + }, + }); + }, + ); + return caches; +} + +export const caches = (cache: CacheStorage) => createLruCacheStorage(cache); diff --git a/runtime/caches/mod.test.ts b/runtime/caches/mod.test.ts new file mode 100644 index 000000000..13d97d625 --- /dev/null +++ b/runtime/caches/mod.test.ts @@ -0,0 +1,139 @@ +import { assert, assertEquals, assertNotEquals } from "@std/assert"; +import { caches as lruCache } from "./lrucache.ts"; +import { caches as headersCache } from "./headerscache.ts"; + +const MAX_CACHE_SIZE = 1073824; + +const NOT_IMPLEMENTED = () => { + throw new Error("Not Implemented"); +}; + +const testCacheStorage = ( + map: Map, +): CacheStorage => { + const getUrl = (request: RequestInfo | URL) => + (request as Request).url ?? request.toString(); + return ( + { + delete: () => { + map.clear(); + return Promise.resolve(true); + }, + has: NOT_IMPLEMENTED, + keys: NOT_IMPLEMENTED, + match: NOT_IMPLEMENTED, + open: () => { + return (Promise.resolve({ + add: NOT_IMPLEMENTED, + addAll: NOT_IMPLEMENTED, + delete: (request) => { + return Promise.resolve(Boolean(map.delete(getUrl(request)))); + }, + keys: NOT_IMPLEMENTED, + match: (request) => { + return Promise.resolve(map.get(getUrl(request))); + }, + matchAll: NOT_IMPLEMENTED, + put: (request, response) => { + map.set(getUrl(request), response); + return Promise.resolve(); + }, + } as Cache)); + }, + } + ); +}; + +const createRequest = (i: number) => new Request(`https://example.com/${i}`); + +const CACHE_NAME = "test"; + +const baseTest = async (cacheStorageUT: CacheStorage) => { + const cache = await headersCache(lruCache(cacheStorageUT)).open(CACHE_NAME); + const response = () => + new Response("Hello, World!", { + headers: { "Content-length": `${MAX_CACHE_SIZE / 2}` }, + }); + for (let i = 0; i < 5; i++) { + const request = createRequest(i); + await cache.put(request, response()); + assert(cache.match(request)); + } + for (let i = 0; i < 3; i++) { + const request = createRequest(i); + const response = await cache.match(request); + assertEquals(response, undefined); + } + for (let i = 3; i < 5; i++) { + const request = createRequest(i); + const response = await cache.match(request); + assertNotEquals(response, undefined); + } +}; + +Deno.test({ + name: "lru_cache_adapter", + sanitizeResources: false, + sanitizeOps: false, +}, async (t) => { + await t.step( + "test base scenario", + async () => { + await baseTest(testCacheStorage(new Map())); + }, + ); +}); + +Deno.test({ + name: "test one resource", + sanitizeResources: false, + sanitizeOps: false, +}, async (t) => { + const testMap = new Map(); + const cache = await headersCache(lruCache(testCacheStorage(testMap))).open( + CACHE_NAME, + ); + await t.step( + "test one resource without content-length", + async () => { + await cache.put(createRequest(0), new Response("Hello, World!")); + const responseWithDiscoveredLength = await cache.match(createRequest(0)); + assertEquals( + responseWithDiscoveredLength?.headers.get("content-length"), + "13", + ); + }, + ); + await t.step( + "test one resource with content-length", + async () => { + await cache.put( + createRequest(1), + new Response("Hello, World!", { + headers: { "content-length": "100" }, + }), + ); + const responseWithContentLength = await cache.match(createRequest(1)); + assertEquals( + responseWithContentLength?.headers.get("content-length"), + "100", + ); + }, + ); +}); + +Deno.test({ + name: "webstandard_cache_with_adapter", + sanitizeResources: false, + sanitizeOps: false, +}, async (t) => { + await t.step( + "test base scenario", + async () => { + caches.delete(CACHE_NAME); + await baseTest(caches); + }, + ); +}); + +// TODO TESTAR O CENARIO ONDE O RESPONSE N TEM LENGTH diff --git a/runtime/caches/mod.ts b/runtime/caches/mod.ts index 19be7c6cb..ae89d9f3c 100644 --- a/runtime/caches/mod.ts +++ b/runtime/caches/mod.ts @@ -1,17 +1,15 @@ import { withInstrumentation } from "./common.ts"; -// import { caches as cachesKV } from "./denoKV.ts"; -import { - caches as cachesFileSystem, - isFileSystemAvailable, -} from "./fileSystem.ts"; -// TODO(mcandeia) s3 and redis are not being used and together they are 30% of the bundle size of deco, -// so we should remove them for now and add it dinamically later. -// import { caches as redisCache, redis } from "./redis.ts"; -// import { caches as cachesS3, isS3Available } from "./s3.ts"; + +import { isFileSystemAvailable } from "./fileSystem.ts"; + +import { caches as headersCache } from "./headerscache.ts"; + import { createTieredCache } from "./tiered.ts"; +import { caches as lruCache } from "./lrucache.ts"; + export const ENABLE_LOADER_CACHE: boolean = - Deno.env.get("ENABLE_LOADER_CACHE") === "true"; + Deno.env.get("ENABLE_LOADER_CACHE") !== "false"; const DEFAULT_CACHE_ENGINE = "CACHE_API"; const WEB_CACHE_ENGINES: CacheEngine[] = Deno.env.has("WEB_CACHE_ENGINE") ? Deno.env.get("WEB_CACHE_ENGINE")!.split(",") as CacheEngine[] @@ -23,35 +21,16 @@ export interface CacheStorageOption { } export type CacheEngine = - // TODO (mcandeia) see line 7. - // | "REDIS" - // | "S3"; - // | "KV" | "CACHE_API" | "FILE_SYSTEM"; -const cacheImplByEngine: Record = { - // TODO (mcandeia) see line 7 - // REDIS: { - // implementation: redisCache, - // isAvailable: redis !== null, - // }, - // S3: { - // implementation: cachesS3, - // isAvailable: isS3Available, - // }, - // KV: { - // implementation: cachesKV, - // // @ts-ignore: Deno type definitions are missing openKv - // isAvailable: typeof Deno.openKv === "function", - // isAvailable: false, - // }, +export const cacheImplByEngine: Record = { CACHE_API: { - implementation: globalThis.caches, + implementation: headersCache(globalThis.caches), isAvailable: typeof globalThis.caches !== "undefined", }, FILE_SYSTEM: { - implementation: cachesFileSystem, + implementation: headersCache(lruCache(globalThis.caches)), isAvailable: isFileSystemAvailable, }, }; diff --git a/runtime/caches/redis.ts b/runtime/caches/redis.ts deleted file mode 100644 index 0698de5c7..000000000 --- a/runtime/caches/redis.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Redis } from "https://deno.land/x/upstash_redis@v1.22.1/mod.ts"; - -import { logger, tracer } from "../../observability/otel/config.ts"; -import { - assertCanBeCached, - assertNoOptions, - withCacheNamespace, -} from "./common.ts"; - -const redisUrl = Deno.env.get("UPSTASH_REDIS_REST_URL"); -const redisToken = Deno.env.get("UPSTASH_REDIS_REST_TOKEN"); - -export const redis = redisUrl && redisToken - ? new Redis({ - url: redisUrl, - token: redisToken, - enableTelemetry: true, - }) - : null; - -interface ResponseMetadata { - body: string; - status: number; - headers: [string, string][]; -} - -function base64encode(str: string): string { - return btoa(encodeURIComponent(str)); -} - -function base64decode(str: string): string { - return decodeURIComponent(atob(str)); -} -export const caches: CacheStorage = { - delete: (_cacheName: string): Promise => { - throw new Error("Not Implemented"); - }, - has: (_cacheName: string): Promise => { - throw new Error("Not Implemented"); - }, - keys: (): Promise => { - throw new Error("Not Implemented"); - }, - match: ( - _request: URL | RequestInfo, - _options?: MultiCacheQueryOptions | undefined, - ): Promise => { - throw new Error("Not Implemented"); - }, - open: (cacheName: string): Promise => { - if (!redis) { - throw new Error( - "Redis coult not be used due to the lack of credentials.", - ); - } - const requestURLSHA1 = withCacheNamespace(cacheName); - return Promise.resolve({ - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/add) */ - add: (_request: RequestInfo | URL): Promise => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/addAll) */ - addAll: (_requests: RequestInfo[]): Promise => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/delete) */ - delete: async ( - request: RequestInfo | URL, - options?: CacheQueryOptions, - ): Promise => { - assertNoOptions(options); - - return await redis.del( - await requestURLSHA1(request), - ) > 0; - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/keys) */ - keys: ( - _request?: RequestInfo | URL, - _options?: CacheQueryOptions, - ): Promise> => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/match) */ - match: async ( - request: RequestInfo | URL, - options?: CacheQueryOptions, - ): Promise => { - assertNoOptions(options); - const cacheKey = await requestURLSHA1(request); - const span = tracer.startSpan("redis-get", { - attributes: { - cacheKey, - }, - }); - try { - const data = await redis.get(cacheKey); - if (data === null) { - span.addEvent("cache-miss"); - return undefined; - } - span.addEvent("cache-hit"); - if (data instanceof Error) { - logger.error( - `error when reading from redis, ${data.toString()}`, - ); - return undefined; - } - - if (typeof data !== "object") { - logger.error( - `data for ${cacheKey} was stored in a invalid format, thus cache will not be used`, - ); - return undefined; - } - - const parsedData: ResponseMetadata = typeof data === "string" - ? JSON.parse(data) - : data; - return new Response(base64decode(parsedData.body), { - status: parsedData.status, - headers: new Headers(parsedData.headers), - }); - } catch (err) { - span.recordException(err); - throw err; - } finally { - span.end(); - } - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/matchAll) */ - matchAll: ( - _request?: RequestInfo | URL, - _options?: CacheQueryOptions, - ): Promise> => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/put) */ - put: async ( - request: RequestInfo | URL, - response: Response, - ): Promise => { - const req = new Request(request); - assertCanBeCached(req, response); - - if (!response.body) { - return; - } - - const cacheKey = await requestURLSHA1(request); - const span = tracer.startSpan("redis-put", { - attributes: { - cacheKey, - }, - }); - - try { - let expires = response.headers.get("expires"); - if (!expires && (response.status >= 300 || response.status < 200)) { //cannot be cached - span.addEvent("cannot-be-cached", { - status: response.status, - expires: expires ?? "undefined", - }); - return; - } - expires ??= new Date(Date.now() + (180_000)).toUTCString(); - - const expDate = new Date(expires); - const timeMs = expDate.getTime() - Date.now(); - if (timeMs <= 0) { - span.addEvent("negative-time-ms", { timeMs: `${timeMs}` }); - return; - } - - response.text().then(base64encode).then((body) => { - const newMeta: ResponseMetadata = { - body, - status: response.status, - headers: [...response.headers.entries()], - }; - - const options = { px: timeMs }; - const setSpan = tracer.startSpan("redis-set", { - attributes: { cacheKey }, - }); - redis.set(cacheKey, JSON.stringify(newMeta), options).catch( - (err) => { - console.error("redis error", err); - setSpan.recordException(err); - }, - ).finally(() => { - setSpan.end(); - }); // do not await for setting cache - }).catch((err) => { - logger.error(`error saving to redis ${err?.message}`); - }); - } catch (err) { - span.recordException(err); - throw err; - } finally { - span.end(); - } - }, - }); - }, -}; diff --git a/runtime/caches/s3.ts b/runtime/caches/s3.ts deleted file mode 100644 index 5e4ae9051..000000000 --- a/runtime/caches/s3.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { - compress, - decompress, - init as initZstd, -} from "https://denopkg.com/mcandeia/zstd-wasm@0.20.2/deno/zstd.ts"; -import { - DeleteObjectCommand, - GetObjectCommand, - NoSuchKey, - PutObjectCommand, - S3Client, -} from "https://esm.sh/@aws-sdk/client-s3@3.513.0"; -import { Context } from "../../deco.ts"; -import { ValueType } from "../../deps.ts"; -import { logger, tracer } from "../../observability/otel/config.ts"; -import { meter } from "../../observability/otel/metrics.ts"; -import { - assertCanBeCached, - assertNoOptions, - withCacheNamespace, -} from "./common.ts"; - -const MAX_UNCOMPRESSED_SIZE = parseInt( - Deno.env.get("CACHE_AWS_MAX_UNCOMPRESSED_SIZE")!, -); - -const zstdPromise = initZstd(); - -const bucketName = Deno.env.get("CACHE_UPLOAD_BUCKET"); -const awsRegion = Deno.env.get("CACHE_AWS_REGION"); -const awsAccessKeyId = Deno.env.get("CACHE_AWS_ACCESS_KEY_ID")!; -const awsSecretAccessKey = Deno.env.get("CACHE_AWS_SECRET_ACCESS_KEY")!; -const awsEndpoint = Deno.env.get("CACHE_AWS_ENDPOINT"); - -const downloadDuration = meter.createHistogram("s3_download_duration", { - description: "s3 download duration", - unit: "ms", - valueType: ValueType.DOUBLE, -}); - -const bufferSizeSumObserver = meter.createUpDownCounter("buffer_size_sum", { - description: "Sum of buffer sizes", - unit: "1", - valueType: ValueType.INT, -}); - -const compressDuration = meter.createHistogram("zstd_compress_duration", { - description: "compress duration", - unit: "ms", - valueType: ValueType.DOUBLE, -}); - -interface Metadata { - body: { - buffer: Uint8Array; // buffer with compressed data - zstd: boolean; - }; -} - -function metadataToUint8Array(metadata: Metadata): Uint8Array { - const { buffer, zstd } = metadata.body; - const zstdArray = new Uint8Array([zstd ? 1 : 0]); - const result = new Uint8Array(buffer.length + zstdArray.length); - result.set(zstdArray); - result.set(buffer, zstdArray.length); - return result; -} - -function createS3Caches(): CacheStorage { - const s3Client = new S3Client({ - region: awsRegion, - credentials: { - accessKeyId: awsAccessKeyId, - secretAccessKey: awsSecretAccessKey, - }, - endpoint: awsEndpoint, - }); - - async function putObject( - key: string, - responseObject: Metadata, - ) { - const result = metadataToUint8Array(responseObject); - - const bucketParams = { - Bucket: bucketName, - Key: `${key}-${Context.active().site}`, - Body: result, - }; - - const command = new PutObjectCommand(bucketParams); - const response = await s3Client.send(command); - - return response; - } - - async function getObject(key: string) { - const bucketParams = { - Bucket: bucketName, - Key: `${key}-${Context.active().site}`, - }; - - const command = new GetObjectCommand(bucketParams); - const response = await s3Client.send(command); - - return response; - } - - async function deleteObject(key: string) { - const bucketParams = { - Bucket: bucketName, - Key: `${key}-${Context.active().site}`, - }; - - const command = new DeleteObjectCommand(bucketParams); - const response = await s3Client.send(command); - - return response; - } - - const caches: CacheStorage = { - delete: (_cacheName: string): Promise => { - throw new Error("Not Implemented"); - }, - has: (_cacheName: string): Promise => { - throw new Error("Not Implemented"); - }, - keys: (): Promise => { - throw new Error("Not Implemented"); - }, - match: ( - _request: URL | RequestInfo, - _options?: MultiCacheQueryOptions | undefined, - ): Promise => { - throw new Error("Not Implemented"); - }, - open: async (cacheName: string): Promise => { - await zstdPromise; - const requestURLSHA1 = withCacheNamespace(cacheName); - - return Promise.resolve({ - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/add) */ - add: (_request: RequestInfo | URL): Promise => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/addAll) */ - addAll: (_requests: RequestInfo[]): Promise => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/delete) */ - delete: async ( - request: RequestInfo | URL, - options?: CacheQueryOptions, - ): Promise => { - assertNoOptions(options); - - const deleteResponse = await deleteObject( - await requestURLSHA1(request), - ); - if (deleteResponse.$metadata.httpStatusCode === undefined) { - return false; - } - return deleteResponse.$metadata.httpStatusCode == 204; - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/keys) */ - keys: ( - _request?: RequestInfo | URL, - _options?: CacheQueryOptions, - ): Promise> => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/match) */ - match: async ( - request: RequestInfo | URL, - options?: CacheQueryOptions, - ): Promise => { - assertNoOptions(options); - const cacheKey = await requestURLSHA1(request); - const span = tracer.startSpan("s3-get", { - attributes: { - cacheKey, - }, - }); - try { - const startTime = performance.now(); - const getResponse = await getObject(cacheKey); - - span.addEvent("s3-get-response"); - if (getResponse.Body === undefined) { - logger.error(`error when reading from s3, ${getResponse}`); - return undefined; - } - const data = await getResponse.Body.transformToByteArray(); - const downloadDurationTime = performance.now() - startTime; - - if (data === null) { - return undefined; - } - - // first byte is a flag to indicate if the buffer is compressed - // check function metadataToUint8Array - const zstd = data[0] === 1; - const buffer = data.slice(1); - - downloadDuration.record(downloadDurationTime, { - bufferSize: buffer.length, - compressed: zstd, - }); - - return new Response( - zstd ? decompress(buffer) : buffer, - ); - } catch (err) { - if (err instanceof NoSuchKey) { - return undefined; - } - throw err; - } finally { - span.end(); - } - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/matchAll) */ - matchAll: ( - _request?: RequestInfo | URL, - _options?: CacheQueryOptions, - ): Promise> => { - throw new Error("Not Implemented"); - }, - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Cache/put) */ - put: async ( - request: RequestInfo | URL, - response: Response, - ): Promise => { - const req = new Request(request); - assertCanBeCached(req, response); - - if (!response.body) { - return; - } - - const cacheKey = await requestURLSHA1(request); - const [buffer, zstd] = await response.arrayBuffer() - .then((buffer) => new Uint8Array(buffer)) - .then((buffer) => { - bufferSizeSumObserver.add(buffer.length); - return buffer; - }) - .then((buffer) => { - if ( - MAX_UNCOMPRESSED_SIZE && buffer.length > MAX_UNCOMPRESSED_SIZE - ) { - const start = performance.now(); - const compressed = compress(buffer, 4); - compressDuration.record(performance.now() - start, { - bufferSize: buffer.length, - compressedSize: compressed.length, - }); - return [compressed, true] as const; - } - return [buffer, false] as const; - }); - - const span = tracer.startSpan("s3-put", { - attributes: { - cacheKey, - }, - }); - - try { - try { - const newMeta: Metadata = { - body: { buffer, zstd }, - }; - - const setSpan = tracer.startSpan("s3-set", { - attributes: { cacheKey }, - }); - putObject(cacheKey, newMeta).catch( - (err) => { - console.error("s3 error", err); - setSpan.recordException(err); - }, - ).finally(() => { - setSpan.end(); - }); // do not await for setting cache - } catch (error) { - logger.error(`error saving to s3 ${error?.message}`); - } - } catch (err) { - span.recordException(err); - throw err; - } finally { - span.end(); - } - }, - }); - }, - }; - return caches; -} -const isEndpointSet = (bucketName !== undefined && awsRegion !== undefined) || - awsEndpoint !== undefined; -const areCredentialsSet = awsAccessKeyId !== undefined && - awsSecretAccessKey !== undefined; -export const isS3Available = isEndpointSet && areCredentialsSet; - -export const caches = createS3Caches(); diff --git a/runtime/caches/utils.ts b/runtime/caches/utils.ts new file mode 100644 index 000000000..b5fd6bb77 --- /dev/null +++ b/runtime/caches/utils.ts @@ -0,0 +1,101 @@ +export const sha1 = async (text: string) => { + const buffer = await crypto.subtle + .digest("SHA-1", new TextEncoder().encode(text)); + + const hex = Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return hex; +}; + +const NOT_IMPLEMENTED = () => { + throw new Error("Not Implemented"); +}; + +export const baseCache = { + add: NOT_IMPLEMENTED, + addAll: NOT_IMPLEMENTED, + delete: NOT_IMPLEMENTED, + keys: NOT_IMPLEMENTED, + match: NOT_IMPLEMENTED, + matchAll: NOT_IMPLEMENTED, + put: NOT_IMPLEMENTED, +}; + +export function createBaseCacheStorage( + cacheStorageInner: CacheStorage, + openCache: ( + cacheName: string, + cacheInner: Cache, + requestURLSHA1: (request: RequestInfo | URL) => Promise, + ) => Promise, +): CacheStorage { + const caches: CacheStorage = { + delete: () => { + throw new Error("Not Implemented"); + }, + has: () => { + throw new Error("Not Implemented"); + }, + keys: () => { + throw new Error("Not Implemented"); + }, + match: () => { + throw new Error("Not Implemented"); + }, + open: async (cacheName: string): Promise => { + const cacheInner = await cacheStorageInner.open(cacheName); + const requestURLSHA1 = (request: RequestInfo | URL) => + withCacheNamespace(cacheName)(request).then((key) => + "http://localhost:8000/" + key + ); + const cache = Promise.resolve( + openCache(cacheName, cacheInner, requestURLSHA1), + ); + return cache; + }, + }; + + return caches; +} + +export const assertNoOptions = ( + { ignoreMethod, ignoreSearch, ignoreVary }: CacheQueryOptions = {}, +) => { + if (ignoreMethod || ignoreSearch || ignoreVary) { + throw new Error("Not Implemented"); + } +}; + +export const requestURL = (request: RequestInfo | URL): string => { + return typeof request === "string" + ? request + : request instanceof URL + ? request.href + : request.url; +}; + +export const withCacheNamespace = + (cacheName: string) => (request: RequestInfo | URL): Promise => { + return requestURLSHA1(request).then((key) => `${key}${cacheName}`); + }; + +export const requestURLSHA1 = (request: RequestInfo | URL): Promise => { + return sha1(requestURL(request)); +}; + +export const assertCanBeCached = (req: Request, response: Response) => { + if (!/^http(s?):\/\//.test(req.url)) { + throw new TypeError( + "Request url protocol must be 'http:' or 'https:'", + ); + } + if (req.method !== "GET") { + throw new TypeError("Request method must be GET"); + } + + if (response.status === 206) { + throw new TypeError("Response status must not be 206"); + } +};