From f6d972c51c907115d80fa15ffe7a2120e2780792 Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Mon, 5 Feb 2024 00:19:07 +0700 Subject: [PATCH 01/12] refactor: extract cache logic into dedicated class --- src/api.ts | 68 +++++++++++++------------------- src/aws.ts | 56 +++++++++++---------------- src/resolvers/cache.ts | 88 ++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 32 +-------------- 4 files changed, 138 insertions(+), 106 deletions(-) create mode 100644 src/resolvers/cache.ts diff --git a/src/api.ts b/src/api.ts index c30fff20..56ddc9ef 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,8 @@ import express from 'express'; import { capture } from '@snapshot-labs/snapshot-sentry'; -import { parseQuery, resize, setHeader, getCacheKey } from './utils'; -import { set, get, streamToBuffer, clear } from './aws'; +import { parseQuery, resize, setHeader } from './utils'; +import { streamToBuffer } from './aws'; +import Cache from './resolvers/cache'; import resolvers from './resolvers'; import constants from './constants.json'; import { rpcError, rpcSuccess } from './helpers/utils'; @@ -33,13 +34,14 @@ router.post('/', async (req, res) => { router.get(`/clear/:type(${TYPE_CONSTRAINTS})/:id`, async (req, res) => { const { type, id } = req.params; try { - const { address, network, w, h, fallback, cb } = await parseQuery(id, type, { - s: constants.max, - fb: req.query.fb, - cb: req.query.cb - }); - const key = getCacheKey({ type, network, address, w, h, fallback, cb }); - const result = await clear(key); + const cache = new Cache( + await parseQuery(id, type, { + s: constants.max, + fb: req.query.fb, + cb: req.query.cb + }) + ); + const result = await cache.clear(); res.status(result ? 200 : 404).json({ status: result ? 'ok' : 'not found' }); } catch (e) { capture(e); @@ -49,42 +51,31 @@ router.get(`/clear/:type(${TYPE_CONSTRAINTS})/:id`, async (req, res) => { router.get(`/:type(${TYPE_CONSTRAINTS})/:id`, async (req, res) => { const { type, id } = req.params; - let address, network, w, h, fallback, cb; + let parsedParams, address, network, w, h, fallback; try { - ({ address, network, w, h, fallback, cb } = await parseQuery(id, type, req.query)); + parsedParams = await parseQuery(id, type, req.query); + ({ address, network, w, h, fallback } = parsedParams); } catch (e) { return res.status(500).json({ status: 'error', error: 'failed to load content' }); } - const key1 = getCacheKey({ - type, - network, - address, - w: constants.max, - h: constants.max, - fallback, - cb - }); - const key2 = getCacheKey({ type, network, address, w, h, fallback, cb }); + const cache = new Cache(parsedParams); // Check resized cache - const cache = await get(`${key1}/${key2}`); - if (cache) { - // console.log('Got cache', address); + const cachedResizedImage = await cache.getResizedImage(); + if (cachedResizedImage) { setHeader(res); - return cache.pipe(res); + return cachedResizedImage.pipe(res); } // Check base cache - const base = await get(`${key1}/${key1}`); - let baseImage; - if (base) { - baseImage = await streamToBuffer(base); - // console.log('Got base cache'); - } else { - // console.log('No cache for', key1, base); + const cachedBaseImage = await cache.getBasedImage(); + let baseImage: Buffer; + if (cachedBaseImage) { + baseImage = await streamToBuffer(cachedBaseImage); + } else { let currentResolvers: string[] = constants.resolvers.avatar; if (type === 'token') currentResolvers = constants.resolvers.token; if (type === 'space') currentResolvers = constants.resolvers.space; @@ -109,17 +100,10 @@ router.get(`/:type(${TYPE_CONSTRAINTS})/:id`, async (req, res) => { res.send(resizedImage); // Store cache - try { - if (!base) { - await set(`${key1}/${key1}`, baseImage); - console.log('Stored base cache', key1); - } - await set(`${key1}/${key2}`, resizedImage); - console.log('Stored cache', address); - } catch (e) { - capture(e); - console.log('Store cache failed', address, e); + if (!cachedBaseImage) { + await cache.setBaseImage(baseImage); } + await cache.setResizedImage(resizedImage); }); export default router; diff --git a/src/aws.ts b/src/aws.ts index 1da04fbe..c95fcb4b 100644 --- a/src/aws.ts +++ b/src/aws.ts @@ -8,7 +8,7 @@ const endpoint = process.env.AWS_ENDPOINT || undefined; if (region) client = new AWS.S3({ region, endpoint }); const dir = 'stamp-3'; -export async function streamToBuffer(stream: Readable) { +export async function streamToBuffer(stream: Readable): Promise { return await new Promise((resolve, reject) => { const chunks: Uint8Array[] = []; stream.on('data', chunk => chunks.push(Buffer.from(chunk))); @@ -18,43 +18,33 @@ export async function streamToBuffer(stream: Readable) { } export async function set(key, value) { - try { - const command = new AWS.PutObjectCommand({ - Bucket: bucket, - Key: `public/${dir}/${key}`, - Body: value, - ContentType: 'image/webp' - }); + const command = new AWS.PutObjectCommand({ + Bucket: bucket, + Key: `public/${dir}/${key}`, + Body: value, + ContentType: 'image/webp' + }); - await client.send(command); - } catch (e) { - console.log('Store cache failed', e); - throw e; - } + return await client.send(command); } -export async function clear(path) { - try { - const listedObjects = await client.listObjectsV2({ - Bucket: bucket, - Prefix: `public/${dir}/${path}` - }); - if (!listedObjects.Contents || listedObjects.Contents.length === 0) return false; - const objs = listedObjects.Contents.map(obj => ({ Key: obj.Key })); - await client.deleteObjects({ - Bucket: bucket, - Delete: { Objects: objs } - }); - if (listedObjects.IsTruncated) await clear(path); - console.log('Cleared cache', path); - return path; - } catch (e) { - console.log('Clear cache failed', e); - throw e; - } +export async function clear(path: string) { + const listedObjects = await client.listObjectsV2({ + Bucket: bucket, + Prefix: `public/${dir}/${path}` + }); + if (!listedObjects.Contents || listedObjects.Contents.length === 0) return false; + const objs = listedObjects.Contents.map(obj => ({ Key: obj.Key })); + await client.deleteObjects({ + Bucket: bucket, + Delete: { Objects: objs } + }); + if (listedObjects.IsTruncated) await clear(path); + console.log('Cleared cache', path); + return path; } -export async function get(key) { +export async function get(key: string) { try { const command = new AWS.GetObjectCommand({ Bucket: bucket, diff --git a/src/resolvers/cache.ts b/src/resolvers/cache.ts new file mode 100644 index 00000000..fcd0090c --- /dev/null +++ b/src/resolvers/cache.ts @@ -0,0 +1,88 @@ +import { createHash } from 'crypto'; +import { set as setCache, get as getCache, clear as clearCache } from '../aws'; +import constants from '../constants.json'; +import { capture } from '@snapshot-labs/snapshot-sentry'; + +export function sha256(str: string) { + return createHash('sha256') + .update(str) + .digest('hex'); +} + +type ParamsType = { + type: string; + network: string; + address: string; + w: number; + h: number; + fallback?: string; + cb?: string; +}; + +export default class Cache { + private baseImageCacheKey: string; + private resizedImageCacheKey: string; + + constructor({ type, network, address, w, h, fallback, cb }: ParamsType) { + const data = { type, network, address, w, h }; + if (fallback !== 'blockie') data['fallback'] = fallback; + if (cb) data['cb'] = cb; + + const baseImageKey = this._buildKey({ ...data, w: constants.max, h: constants.max }); + const resizedImageKey = this._buildKey(data); + + this.baseImageCacheKey = `${baseImageKey}/${baseImageKey}`; + this.resizedImageCacheKey = `${baseImageKey}/${resizedImageKey}`; + } + + async getBasedImage() { + return await this._getCache(this.baseImageCacheKey); + } + + async getResizedImage() { + return await this._getCache(this.resizedImageCacheKey); + } + + async setBaseImage(value: Buffer) { + return await this._setCache(this.baseImageCacheKey, value); + } + + async setResizedImage(value: Buffer) { + return await this._setCache(this.resizedImageCacheKey, value); + } + + async clear() { + try { + return await clearCache(this.baseImageCacheKey); + } catch (e) { + console.log(`[cache:resolver] Failed to clear cache ${this.baseImageCacheKey}`); + capture(e); + return false; + } + } + + private async _getCache(key: string) { + try { + console.log(`[cache:resolver] Getting cache ${key}`); + return await getCache(key); + } catch (e) { + capture(e); + console.log(`[cache:resolver] Failed to get cache ${key}`); + return null; + } + } + + private async _setCache(key: string, value: Buffer) { + try { + console.log(`[cache:resolver] Setting cache ${key}`); + return await setCache(key, value); + } catch (e) { + capture(e); + console.log(`[cache:resolver] Failed to set cache ${key}`); + } + } + + private _buildKey(params: ParamsType): string { + return sha256(JSON.stringify(params)); + } +} diff --git a/src/utils.ts b/src/utils.ts index 002e3b32..4de44d24 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,3 @@ -import { createHash } from 'crypto'; import sharp from 'sharp'; import { Response } from 'express'; import { StaticJsonRpcProvider } from '@ethersproject/providers'; @@ -21,12 +20,6 @@ export function getProvider(network: number): StaticJsonRpcProvider { return providers[`_${network}`]; } -export function sha256(str) { - return createHash('sha256') - .update(str) - .digest('hex'); -} - export async function resize(input, w, h) { return sharp(input) .resize(w, h) @@ -76,6 +69,7 @@ export async function parseQuery(id, type, query) { if (h < 1 || h > maxSize || isNaN(h)) h = size; return { + type, address, network, w, @@ -100,30 +94,6 @@ export function getUrl(url) { return snapshot.utils.getUrl(url, gateway); } -export function getCacheKey({ - type, - network, - address, - w, - h, - fallback, - cb -}: { - type: string; - network: string; - address: string; - w: number; - h: number; - fallback: string; - cb?: string; -}) { - const data = { type, network, address, w, h }; - if (fallback !== 'blockie') data['fallback'] = fallback; - if (cb) data['cb'] = cb; - - return sha256(JSON.stringify(data)); -} - export function setHeader(res: Response, cacheType: 'SHORT_CACHE' | 'LONG_CACHE' = 'LONG_CACHE') { const ttl = cacheType === 'SHORT_CACHE' ? constants.shortTtl : constants.ttl; From d4199fa042062551604c47a8303ffd944bf2f64f Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Mon, 5 Feb 2024 00:43:45 +0700 Subject: [PATCH 02/12] fix: instrument the image resolver cache --- src/helpers/metrics.ts | 6 ++++++ src/resolvers/cache.ts | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/helpers/metrics.ts b/src/helpers/metrics.ts index b468f215..e834f3dc 100644 --- a/src/helpers/metrics.ts +++ b/src/helpers/metrics.ts @@ -31,3 +31,9 @@ export const addressResolversCacheHitCount = new client.Counter({ help: 'Number of hit/miss of the address resolvers cache layer', labelNames: ['status'] }); + +export const imageResolversCacheHitCount = new client.Counter({ + name: 'image_resolvers_cache_hit_count', + help: 'Number of hit/miss of the image resolvers cache layer', + labelNames: ['status'] +}); diff --git a/src/resolvers/cache.ts b/src/resolvers/cache.ts index fcd0090c..17deb463 100644 --- a/src/resolvers/cache.ts +++ b/src/resolvers/cache.ts @@ -1,6 +1,7 @@ import { createHash } from 'crypto'; import { set as setCache, get as getCache, clear as clearCache } from '../aws'; import constants from '../constants.json'; +import { imageResolversCacheHitCount } from '../helpers/metrics'; import { capture } from '@snapshot-labs/snapshot-sentry'; export function sha256(str: string) { @@ -64,7 +65,11 @@ export default class Cache { private async _getCache(key: string) { try { console.log(`[cache:resolver] Getting cache ${key}`); - return await getCache(key); + const cache = await getCache(key); + + imageResolversCacheHitCount.inc({ status: cache ? 'HIT' : 'MISS' }, 1); + + return cache; } catch (e) { capture(e); console.log(`[cache:resolver] Failed to get cache ${key}`); From c41d36e7443509eaf01fb1c100173b9635d52309 Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Mon, 5 Feb 2024 14:50:48 +0700 Subject: [PATCH 03/12] chore: add tests skeleton --- test/integration/resolvers/cache.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 test/integration/resolvers/cache.test.ts diff --git a/test/integration/resolvers/cache.test.ts b/test/integration/resolvers/cache.test.ts new file mode 100644 index 00000000..3efa9e41 --- /dev/null +++ b/test/integration/resolvers/cache.test.ts @@ -0,0 +1,21 @@ +describe('image resolver cache', () => { + describe('getBaseImage', () => { + it.todo('should return the cached image'); + }); + + describe('getResizedImage', () => { + it.todo('should return the cached image'); + }); + + describe('setBaseImage', () => { + it.todo('should save the image in the cache'); + }); + + describe('setResizedImage', () => { + it.todo('should save the image in the cache'); + }); + + describe('clear', () => { + it.todo('should clear the cache'); + }); +}); From dc6471faafd5d1e8f00bb29af0b8916cf57fc9d7 Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Mon, 5 Feb 2024 15:06:20 +0700 Subject: [PATCH 04/12] refactor: add more types --- src/aws.ts | 12 +++++------- src/resolvers/cache.ts | 9 +++++---- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/aws.ts b/src/aws.ts index c95fcb4b..7b867e67 100644 --- a/src/aws.ts +++ b/src/aws.ts @@ -17,7 +17,7 @@ export async function streamToBuffer(stream: Readable): Promise { }); } -export async function set(key, value) { +export async function set(key: string, value: Buffer) { const command = new AWS.PutObjectCommand({ Bucket: bucket, Key: `public/${dir}/${key}`, @@ -28,7 +28,7 @@ export async function set(key, value) { return await client.send(command); } -export async function clear(path: string) { +export async function clear(path: string): Promise { const listedObjects = await client.listObjectsV2({ Bucket: bucket, Prefix: `public/${dir}/${path}` @@ -41,19 +41,17 @@ export async function clear(path: string) { }); if (listedObjects.IsTruncated) await clear(path); console.log('Cleared cache', path); - return path; + return true; } -export async function get(key: string) { +export async function get(key: string): Promise { try { const command = new AWS.GetObjectCommand({ Bucket: bucket, Key: `public/${dir}/${key}` }); - const { Body } = await client.send(command); - - return Body; + return (await client.send(command)).Body; } catch (e) { return false; } diff --git a/src/resolvers/cache.ts b/src/resolvers/cache.ts index 17deb463..b742cee4 100644 --- a/src/resolvers/cache.ts +++ b/src/resolvers/cache.ts @@ -1,4 +1,5 @@ import { createHash } from 'crypto'; +import { Readable } from 'stream'; import { set as setCache, get as getCache, clear as clearCache } from '../aws'; import constants from '../constants.json'; import { imageResolversCacheHitCount } from '../helpers/metrics'; @@ -36,11 +37,11 @@ export default class Cache { this.resizedImageCacheKey = `${baseImageKey}/${resizedImageKey}`; } - async getBasedImage() { + async getBasedImage(): Promise { return await this._getCache(this.baseImageCacheKey); } - async getResizedImage() { + async getResizedImage(): Promise { return await this._getCache(this.resizedImageCacheKey); } @@ -52,7 +53,7 @@ export default class Cache { return await this._setCache(this.resizedImageCacheKey, value); } - async clear() { + async clear(): Promise { try { return await clearCache(this.baseImageCacheKey); } catch (e) { @@ -73,7 +74,7 @@ export default class Cache { } catch (e) { capture(e); console.log(`[cache:resolver] Failed to get cache ${key}`); - return null; + return false; } } From 339460b46a49507ebe5ab5a31d680036726dd276 Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Mon, 5 Feb 2024 15:06:35 +0700 Subject: [PATCH 05/12] chore: add more tests placeholder --- test/integration/resolvers/cache.test.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/test/integration/resolvers/cache.test.ts b/test/integration/resolvers/cache.test.ts index 3efa9e41..400d0284 100644 --- a/test/integration/resolvers/cache.test.ts +++ b/test/integration/resolvers/cache.test.ts @@ -1,10 +1,22 @@ describe('image resolver cache', () => { describe('getBaseImage', () => { - it.todo('should return the cached image'); + describe('when the image is cached', () => { + it.todo('should return the cached image'); + }); + + describe('when the image is not cached', () => { + it.todo('should return false'); + }); }); describe('getResizedImage', () => { - it.todo('should return the cached image'); + describe('when the image is cached', () => { + it.todo('should return the cached image'); + }); + + describe('when the image is not cached', () => { + it.todo('should return false'); + }); }); describe('setBaseImage', () => { @@ -16,6 +28,12 @@ describe('image resolver cache', () => { }); describe('clear', () => { - it.todo('should clear the cache'); + describe('when the cache exist', () => { + it.todo('should clear the cache'); + }); + + describe('when the cache does not exist', () => { + it.todo('should return false'); + }); }); }); From 127f1e9ff191b65619c6806017e3c09e01f14566 Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Mon, 5 Feb 2024 16:42:03 +0700 Subject: [PATCH 06/12] chore: implement tests --- src/api.ts | 7 +- src/aws.ts | 1 - src/resolvers/cache.ts | 12 ++- test/fixtures/sample.webp | Bin 0 -> 30320 bytes test/integration/resolvers/cache.test.ts | 106 ++++++++++++++++++++--- 5 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 test/fixtures/sample.webp diff --git a/src/api.ts b/src/api.ts index 56ddc9ef..8ecd7b01 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,5 @@ import express from 'express'; +import { Readable } from 'stream'; import { capture } from '@snapshot-labs/snapshot-sentry'; import { parseQuery, resize, setHeader } from './utils'; import { streamToBuffer } from './aws'; @@ -66,15 +67,15 @@ router.get(`/:type(${TYPE_CONSTRAINTS})/:id`, async (req, res) => { const cachedResizedImage = await cache.getResizedImage(); if (cachedResizedImage) { setHeader(res); - return cachedResizedImage.pipe(res); + return (cachedResizedImage as Readable).pipe(res); } // Check base cache - const cachedBaseImage = await cache.getBasedImage(); + const cachedBaseImage = await cache.getBaseImage(); let baseImage: Buffer; if (cachedBaseImage) { - baseImage = await streamToBuffer(cachedBaseImage); + baseImage = await streamToBuffer(cachedBaseImage as Readable); } else { let currentResolvers: string[] = constants.resolvers.avatar; if (type === 'token') currentResolvers = constants.resolvers.token; diff --git a/src/aws.ts b/src/aws.ts index 7b867e67..292647c6 100644 --- a/src/aws.ts +++ b/src/aws.ts @@ -40,7 +40,6 @@ export async function clear(path: string): Promise { Delete: { Objects: objs } }); if (listedObjects.IsTruncated) await clear(path); - console.log('Cleared cache', path); return true; } diff --git a/src/resolvers/cache.ts b/src/resolvers/cache.ts index b742cee4..6b48c1f9 100644 --- a/src/resolvers/cache.ts +++ b/src/resolvers/cache.ts @@ -22,8 +22,8 @@ type ParamsType = { }; export default class Cache { - private baseImageCacheKey: string; - private resizedImageCacheKey: string; + baseImageCacheKey: string; + resizedImageCacheKey: string; constructor({ type, network, address, w, h, fallback, cb }: ParamsType) { const data = { type, network, address, w, h }; @@ -37,7 +37,7 @@ export default class Cache { this.resizedImageCacheKey = `${baseImageKey}/${resizedImageKey}`; } - async getBasedImage(): Promise { + async getBaseImage(): Promise { return await this._getCache(this.baseImageCacheKey); } @@ -55,7 +55,11 @@ export default class Cache { async clear(): Promise { try { - return await clearCache(this.baseImageCacheKey); + const result = await clearCache(this.baseImageCacheKey); + + console.log(`[cache:resolver] Cached cleared ${this.baseImageCacheKey}`); + + return result; } catch (e) { console.log(`[cache:resolver] Failed to clear cache ${this.baseImageCacheKey}`); capture(e); diff --git a/test/fixtures/sample.webp b/test/fixtures/sample.webp new file mode 100644 index 0000000000000000000000000000000000000000..122741b605f3121d393829ffb5b7a0924db13c86 GIT binary patch literal 30320 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 TU=pkm8Veub3_|1vJMaJi)t)C5 literal 0 HcmV?d00001 diff --git a/test/integration/resolvers/cache.test.ts b/test/integration/resolvers/cache.test.ts index 400d0284..4253cfcf 100644 --- a/test/integration/resolvers/cache.test.ts +++ b/test/integration/resolvers/cache.test.ts @@ -1,39 +1,121 @@ +import path from 'path'; +import fs from 'fs'; +import { Readable } from 'stream'; +import Cache from '../../../src/resolvers/cache'; +import { parseQuery } from '../../../src/utils'; +import { set, streamToBuffer } from '../../../src/aws'; +import constants from '../../../src/constants.json'; + +const image_buffer = fs.readFileSync(path.join(__dirname, '../../fixtures/sample.webp')); + describe('image resolver cache', () => { - describe('getBaseImage', () => { + let cache: Cache; + + describe('getBaseImage()', () => { + afterEach(async () => { + await cache.clear(); + }); + describe('when the image is cached', () => { - it.todo('should return the cached image'); + it('should return the cached image', async () => { + const parsedQuery = await parseQuery('0x0-test-getbaseimage', 'avatar', {}); + cache = new Cache(parsedQuery); + + await set(cache.baseImageCacheKey, image_buffer); + const result = await cache.getBaseImage(); + return expect(streamToBuffer(result as Readable)).resolves.toEqual(image_buffer); + }); }); describe('when the image is not cached', () => { - it.todo('should return false'); + it('should return false', async () => { + const parsedQuery = await parseQuery('0x1-test-getbaseimage', 'avatar', {}); + cache = new Cache(parsedQuery); + + return expect(cache.getBaseImage()).resolves.toBe(false); + }); }); }); - describe('getResizedImage', () => { + describe('getResizedImage()', () => { + afterEach(async () => { + await cache.clear(); + }); + describe('when the image is cached', () => { - it.todo('should return the cached image'); + it('should return the cached image', async () => { + const parsedQuery = await parseQuery('0x0-test-getresizedimage', 'avatar', {}); + cache = new Cache(parsedQuery); + + await set(cache.resizedImageCacheKey, image_buffer); + const result = await cache.getResizedImage(); + return expect(streamToBuffer(result as Readable)).resolves.toEqual(image_buffer); + }); }); describe('when the image is not cached', () => { - it.todo('should return false'); + it('should return false', async () => { + const parsedQuery = await parseQuery('0x1-test-getresizedimage', 'avatar', {}); + cache = new Cache(parsedQuery); + + return expect(cache.getResizedImage()).resolves.toBe(false); + }); }); }); describe('setBaseImage', () => { - it.todo('should save the image in the cache'); + it('should save the image in the cache', async () => { + const parsedQuery = await parseQuery('0x0-set-base-image', 'avatar', {}); + cache = new Cache(parsedQuery); + + return expect(cache.setBaseImage(image_buffer)).resolves.toEqual( + expect.objectContaining({ + $metadata: expect.objectContaining({ httpStatusCode: 200 }) + }) + ); + }); }); - describe('setResizedImage', () => { - it.todo('should save the image in the cache'); + describe('setResizedImage()', () => { + it('should save the image in the cache', async () => { + const parsedQuery = await parseQuery('0x0-set-resized-image', 'avatar', {}); + cache = new Cache(parsedQuery); + + return expect(cache.setResizedImage(image_buffer)).resolves.toEqual( + expect.objectContaining({ + $metadata: expect.objectContaining({ httpStatusCode: 200 }) + }) + ); + }); }); - describe('clear', () => { + describe('clear()', () => { describe('when the cache exist', () => { - it.todo('should clear the cache'); + it('should clear the cache', async () => { + const parsedQuery = await parseQuery('0x0-clear-exist', 'avatar', { + s: constants.max, + fb: 'fb-0', + cb: 'cb-0' + }); + cache = new Cache(parsedQuery); + await cache.setBaseImage(image_buffer); + + expect(cache.clear()).resolves.toBe(true); + expect(cache.getBaseImage()).resolves.toBe(false); + expect(cache.getResizedImage()).resolves.toBe(false); + }); }); describe('when the cache does not exist', () => { - it.todo('should return false'); + it('should return false', async () => { + const parsedQuery = await parseQuery('0x0-clear-not-exist', 'avatar', { + s: constants.max, + fb: 'fb-1', + cb: 'cb-1' + }); + cache = new Cache(parsedQuery); + return expect(cache.clear()).resolves.toBe(false); + }); }); }); }); From fa4b17305e52bd3825e5876d8889b0d6b1fc67db Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Mon, 5 Feb 2024 16:47:34 +0700 Subject: [PATCH 07/12] chore: fix tests --- test/integration/resolvers/cache.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/integration/resolvers/cache.test.ts b/test/integration/resolvers/cache.test.ts index 4253cfcf..a84bd67c 100644 --- a/test/integration/resolvers/cache.test.ts +++ b/test/integration/resolvers/cache.test.ts @@ -100,6 +100,10 @@ describe('image resolver cache', () => { cache = new Cache(parsedQuery); await cache.setBaseImage(image_buffer); + await new Promise(resolve => { + setTimeout(resolve, 3e3); + }); + expect(cache.clear()).resolves.toBe(true); expect(cache.getBaseImage()).resolves.toBe(false); expect(cache.getResizedImage()).resolves.toBe(false); From c59d8082c06f9c6ff7c7a2ce925270453814ad60 Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:14:44 +0700 Subject: [PATCH 08/12] fix: output warning on missing aws cache config --- src/aws.ts | 11 +++++++---- src/resolvers/cache.ts | 14 +++++++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/aws.ts b/src/aws.ts index 292647c6..5f535261 100644 --- a/src/aws.ts +++ b/src/aws.ts @@ -1,12 +1,15 @@ import * as AWS from '@aws-sdk/client-s3'; import { Readable } from 'stream'; -let client; +let client: AWS.S3; +const dir = 'stamp-3'; const bucket = process.env.AWS_BUCKET_NAME; const region = process.env.AWS_REGION; const endpoint = process.env.AWS_ENDPOINT || undefined; -if (region) client = new AWS.S3({ region, endpoint }); -const dir = 'stamp-3'; + +export const isConfigured = !!(bucket && region); + +if (isConfigured) client = new AWS.S3({ region, endpoint }); export async function streamToBuffer(stream: Readable): Promise { return await new Promise((resolve, reject) => { @@ -50,7 +53,7 @@ export async function get(key: string): Promise { Key: `public/${dir}/${key}` }); - return (await client.send(command)).Body; + return (await client.send(command)).Body as Readable; } catch (e) { return false; } diff --git a/src/resolvers/cache.ts b/src/resolvers/cache.ts index 6b48c1f9..a5191d90 100644 --- a/src/resolvers/cache.ts +++ b/src/resolvers/cache.ts @@ -1,6 +1,6 @@ import { createHash } from 'crypto'; import { Readable } from 'stream'; -import { set as setCache, get as getCache, clear as clearCache } from '../aws'; +import { set as setCache, get as getCache, clear as clearCache, isConfigured } from '../aws'; import constants from '../constants.json'; import { imageResolversCacheHitCount } from '../helpers/metrics'; import { capture } from '@snapshot-labs/snapshot-sentry'; @@ -24,6 +24,7 @@ type ParamsType = { export default class Cache { baseImageCacheKey: string; resizedImageCacheKey: string; + isConfigured: boolean; constructor({ type, network, address, w, h, fallback, cb }: ParamsType) { const data = { type, network, address, w, h }; @@ -35,6 +36,11 @@ export default class Cache { this.baseImageCacheKey = `${baseImageKey}/${baseImageKey}`; this.resizedImageCacheKey = `${baseImageKey}/${resizedImageKey}`; + this.isConfigured = isConfigured; + + if (!this.isConfigured) { + console.log('[cache:resolver] Cache is not configured'); + } } async getBaseImage(): Promise { @@ -54,6 +60,8 @@ export default class Cache { } async clear(): Promise { + if (this.isConfigured) return false; + try { const result = await clearCache(this.baseImageCacheKey); @@ -68,6 +76,8 @@ export default class Cache { } private async _getCache(key: string) { + if (this.isConfigured) return false; + try { console.log(`[cache:resolver] Getting cache ${key}`); const cache = await getCache(key); @@ -83,6 +93,8 @@ export default class Cache { } private async _setCache(key: string, value: Buffer) { + if (this.isConfigured) return false; + try { console.log(`[cache:resolver] Setting cache ${key}`); return await setCache(key, value); From 2c75c185d157d5f4a553f6fc1e1b2042173af381 Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:16:23 +0700 Subject: [PATCH 09/12] chore: disable tests when cache is not configured --- test/integration/resolvers/cache.test.ts | 176 ++++++++++++----------- 1 file changed, 90 insertions(+), 86 deletions(-) diff --git a/test/integration/resolvers/cache.test.ts b/test/integration/resolvers/cache.test.ts index a84bd67c..5474e590 100644 --- a/test/integration/resolvers/cache.test.ts +++ b/test/integration/resolvers/cache.test.ts @@ -3,7 +3,7 @@ import fs from 'fs'; import { Readable } from 'stream'; import Cache from '../../../src/resolvers/cache'; import { parseQuery } from '../../../src/utils'; -import { set, streamToBuffer } from '../../../src/aws'; +import { isConfigured, set, streamToBuffer } from '../../../src/aws'; import constants from '../../../src/constants.json'; const image_buffer = fs.readFileSync(path.join(__dirname, '../../fixtures/sample.webp')); @@ -11,115 +11,119 @@ const image_buffer = fs.readFileSync(path.join(__dirname, '../../fixtures/sample describe('image resolver cache', () => { let cache: Cache; - describe('getBaseImage()', () => { - afterEach(async () => { - await cache.clear(); - }); + if (isConfigured) { + it.todo('needs to configure the cache for the tests to run'); + } else { + describe('getBaseImage()', () => { + afterEach(async () => { + await cache.clear(); + }); - describe('when the image is cached', () => { - it('should return the cached image', async () => { - const parsedQuery = await parseQuery('0x0-test-getbaseimage', 'avatar', {}); - cache = new Cache(parsedQuery); + describe('when the image is cached', () => { + it('should return the cached image', async () => { + const parsedQuery = await parseQuery('0x0-test-getbaseimage', 'avatar', {}); + cache = new Cache(parsedQuery); - await set(cache.baseImageCacheKey, image_buffer); - const result = await cache.getBaseImage(); - return expect(streamToBuffer(result as Readable)).resolves.toEqual(image_buffer); + await set(cache.baseImageCacheKey, image_buffer); + const result = await cache.getBaseImage(); + return expect(streamToBuffer(result as Readable)).resolves.toEqual(image_buffer); + }); }); - }); - describe('when the image is not cached', () => { - it('should return false', async () => { - const parsedQuery = await parseQuery('0x1-test-getbaseimage', 'avatar', {}); - cache = new Cache(parsedQuery); + describe('when the image is not cached', () => { + it('should return false', async () => { + const parsedQuery = await parseQuery('0x1-test-getbaseimage', 'avatar', {}); + cache = new Cache(parsedQuery); - return expect(cache.getBaseImage()).resolves.toBe(false); + return expect(cache.getBaseImage()).resolves.toBe(false); + }); }); }); - }); - describe('getResizedImage()', () => { - afterEach(async () => { - await cache.clear(); + describe('getResizedImage()', () => { + afterEach(async () => { + await cache.clear(); + }); + + describe('when the image is cached', () => { + it('should return the cached image', async () => { + const parsedQuery = await parseQuery('0x0-test-getresizedimage', 'avatar', {}); + cache = new Cache(parsedQuery); + + await set(cache.resizedImageCacheKey, image_buffer); + const result = await cache.getResizedImage(); + return expect(streamToBuffer(result as Readable)).resolves.toEqual(image_buffer); + }); + }); + + describe('when the image is not cached', () => { + it('should return false', async () => { + const parsedQuery = await parseQuery('0x1-test-getresizedimage', 'avatar', {}); + cache = new Cache(parsedQuery); + + return expect(cache.getResizedImage()).resolves.toBe(false); + }); + }); }); - describe('when the image is cached', () => { - it('should return the cached image', async () => { - const parsedQuery = await parseQuery('0x0-test-getresizedimage', 'avatar', {}); + describe('setBaseImage', () => { + it('should save the image in the cache', async () => { + const parsedQuery = await parseQuery('0x0-set-base-image', 'avatar', {}); cache = new Cache(parsedQuery); - await set(cache.resizedImageCacheKey, image_buffer); - const result = await cache.getResizedImage(); - return expect(streamToBuffer(result as Readable)).resolves.toEqual(image_buffer); + return expect(cache.setBaseImage(image_buffer)).resolves.toEqual( + expect.objectContaining({ + $metadata: expect.objectContaining({ httpStatusCode: 200 }) + }) + ); }); }); - describe('when the image is not cached', () => { - it('should return false', async () => { - const parsedQuery = await parseQuery('0x1-test-getresizedimage', 'avatar', {}); + describe('setResizedImage()', () => { + it('should save the image in the cache', async () => { + const parsedQuery = await parseQuery('0x0-set-resized-image', 'avatar', {}); cache = new Cache(parsedQuery); - return expect(cache.getResizedImage()).resolves.toBe(false); + return expect(cache.setResizedImage(image_buffer)).resolves.toEqual( + expect.objectContaining({ + $metadata: expect.objectContaining({ httpStatusCode: 200 }) + }) + ); }); }); - }); - - describe('setBaseImage', () => { - it('should save the image in the cache', async () => { - const parsedQuery = await parseQuery('0x0-set-base-image', 'avatar', {}); - cache = new Cache(parsedQuery); - - return expect(cache.setBaseImage(image_buffer)).resolves.toEqual( - expect.objectContaining({ - $metadata: expect.objectContaining({ httpStatusCode: 200 }) - }) - ); - }); - }); - - describe('setResizedImage()', () => { - it('should save the image in the cache', async () => { - const parsedQuery = await parseQuery('0x0-set-resized-image', 'avatar', {}); - cache = new Cache(parsedQuery); - - return expect(cache.setResizedImage(image_buffer)).resolves.toEqual( - expect.objectContaining({ - $metadata: expect.objectContaining({ httpStatusCode: 200 }) - }) - ); - }); - }); - - describe('clear()', () => { - describe('when the cache exist', () => { - it('should clear the cache', async () => { - const parsedQuery = await parseQuery('0x0-clear-exist', 'avatar', { - s: constants.max, - fb: 'fb-0', - cb: 'cb-0' - }); - cache = new Cache(parsedQuery); - await cache.setBaseImage(image_buffer); - await new Promise(resolve => { - setTimeout(resolve, 3e3); + describe('clear()', () => { + describe('when the cache exist', () => { + it('should clear the cache', async () => { + const parsedQuery = await parseQuery('0x0-clear-exist', 'avatar', { + s: constants.max, + fb: 'fb-0', + cb: 'cb-0' + }); + cache = new Cache(parsedQuery); + await cache.setBaseImage(image_buffer); + + await new Promise(resolve => { + setTimeout(resolve, 3e3); + }); + + expect(cache.clear()).resolves.toBe(true); + expect(cache.getBaseImage()).resolves.toBe(false); + expect(cache.getResizedImage()).resolves.toBe(false); }); - - expect(cache.clear()).resolves.toBe(true); - expect(cache.getBaseImage()).resolves.toBe(false); - expect(cache.getResizedImage()).resolves.toBe(false); }); - }); - describe('when the cache does not exist', () => { - it('should return false', async () => { - const parsedQuery = await parseQuery('0x0-clear-not-exist', 'avatar', { - s: constants.max, - fb: 'fb-1', - cb: 'cb-1' + describe('when the cache does not exist', () => { + it('should return false', async () => { + const parsedQuery = await parseQuery('0x0-clear-not-exist', 'avatar', { + s: constants.max, + fb: 'fb-1', + cb: 'cb-1' + }); + cache = new Cache(parsedQuery); + return expect(cache.clear()).resolves.toBe(false); }); - cache = new Cache(parsedQuery); - return expect(cache.clear()).resolves.toBe(false); }); }); - }); + } }); From a2a51bcc202330ed38092344e9a1102ee471b6da Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:23:08 +0700 Subject: [PATCH 10/12] fix: fix invalid condition --- src/resolvers/cache.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/resolvers/cache.ts b/src/resolvers/cache.ts index a5191d90..241f074e 100644 --- a/src/resolvers/cache.ts +++ b/src/resolvers/cache.ts @@ -60,7 +60,7 @@ export default class Cache { } async clear(): Promise { - if (this.isConfigured) return false; + if (!this.isConfigured) return false; try { const result = await clearCache(this.baseImageCacheKey); @@ -76,7 +76,7 @@ export default class Cache { } private async _getCache(key: string) { - if (this.isConfigured) return false; + if (!this.isConfigured) return false; try { console.log(`[cache:resolver] Getting cache ${key}`); @@ -93,7 +93,7 @@ export default class Cache { } private async _setCache(key: string, value: Buffer) { - if (this.isConfigured) return false; + if (!this.isConfigured) return false; try { console.log(`[cache:resolver] Setting cache ${key}`); From 6879d131b697d5607022d46b985545cdbc7e1829 Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:25:12 +0700 Subject: [PATCH 11/12] fix: fix invalid condition --- jest.config.ts | 3 ++- test/integration/resolvers/cache.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/jest.config.ts b/jest.config.ts index c4089d1a..192281d2 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -15,5 +15,6 @@ export default { testEnvironment: 'node', setupFiles: ['dotenv/config'], testPathIgnorePatterns: ['/node_modules/', '/dist/', '/test/fixtures/'], - moduleFileExtensions: ['js', 'ts'] + moduleFileExtensions: ['js', 'ts'], + verbose: true }; diff --git a/test/integration/resolvers/cache.test.ts b/test/integration/resolvers/cache.test.ts index 5474e590..1ae61321 100644 --- a/test/integration/resolvers/cache.test.ts +++ b/test/integration/resolvers/cache.test.ts @@ -11,7 +11,7 @@ const image_buffer = fs.readFileSync(path.join(__dirname, '../../fixtures/sample describe('image resolver cache', () => { let cache: Cache; - if (isConfigured) { + if (!isConfigured) { it.todo('needs to configure the cache for the tests to run'); } else { describe('getBaseImage()', () => { From b4a002db0f676d15e10d8860dda5d2638040996b Mon Sep 17 00:00:00 2001 From: Chaitanya Date: Mon, 4 Mar 2024 15:59:47 +0530 Subject: [PATCH 12/12] Update aws.ts --- src/aws.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/aws.ts b/src/aws.ts index 934ea71c..42d6788f 100644 --- a/src/aws.ts +++ b/src/aws.ts @@ -2,12 +2,11 @@ import * as AWS from '@aws-sdk/client-s3'; import { Readable } from 'stream'; let client: AWS.S3; -const dir = 'stamp-3'; +const dir = 'stamp-4'; const bucket = process.env.AWS_BUCKET_NAME; const region = process.env.AWS_REGION; const endpoint = process.env.AWS_ENDPOINT || undefined; if (region) client = new AWS.S3({ region, endpoint }); -const dir = 'stamp-4'; export const isConfigured = !!(bucket && region);