diff --git a/src/api/routes/recursion.ts b/src/api/routes/recursion.ts index 8b139e45..9624984b 100644 --- a/src/api/routes/recursion.ts +++ b/src/api/routes/recursion.ts @@ -9,14 +9,14 @@ import { BlockTimestampResponse, NotFoundResponse, } from '../schemas'; +import { handleBlockHashCache, handleBlockHeightCache } from '../util/cache'; const IndexRoutes: FastifyPluginCallback, Server, TypeBoxTypeProvider> = ( fastify, options, done ) => { - // todo: add blockheight cache? or re-use the inscriptions per block cache (since that would invalidate on gaps as well) - // fastify.addHook('preHandler', handleInscriptionTransfersCache); + fastify.addHook('preHandler', handleBlockHashCache); fastify.get( '/blockheight', @@ -90,8 +90,7 @@ const ShowRoutes: FastifyPluginCallback, Server, TypeBoxTyp options, done ) => { - // todo: add blockheight cache? or re-use the inscriptions per block cache (since that would invalidate on gaps as well) - // fastify.addHook('preHandler', handleInscriptionCache); + fastify.addHook('preHandler', handleBlockHeightCache); fastify.get( '/blockhash/:block_height', diff --git a/src/api/util/cache.ts b/src/api/util/cache.ts index 8a17625e..cf8d12c7 100644 --- a/src/api/util/cache.ts +++ b/src/api/util/cache.ts @@ -1,11 +1,17 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import { logger } from '../../logger'; -import { InscriptionIdParamCType, InscriptionNumberParamCType } from '../schemas'; +import { + BlockHeightParamCType, + InscriptionIdParamCType, + InscriptionNumberParamCType, +} from '../schemas'; export enum ETagType { inscriptionTransfers, inscription, inscriptionsPerBlock, + blockHash, + blockHeight, } /** @@ -34,6 +40,14 @@ export async function handleInscriptionsPerBlockCache( return handleCache(ETagType.inscriptionsPerBlock, request, reply); } +export async function handleBlockHashCache(request: FastifyRequest, reply: FastifyReply) { + return handleCache(ETagType.blockHash, request, reply); +} + +export async function handleBlockHeightCache(request: FastifyRequest, reply: FastifyReply) { + return handleCache(ETagType.blockHeight, request, reply); +} + async function handleCache(type: ETagType, request: FastifyRequest, reply: FastifyReply) { const ifNoneMatch = parseIfNoneMatchHeader(request.headers['if-none-match']); let etag: string | undefined; @@ -47,9 +61,15 @@ async function handleCache(type: ETagType, request: FastifyRequest, reply: Fasti case ETagType.inscriptionsPerBlock: etag = await request.server.db.getInscriptionsPerBlockETag(); break; + case ETagType.blockHash: + etag = await request.server.db.getBlockHashETag(); + break; + case ETagType.blockHeight: + etag = await getBlockHeightEtag(request); + break; } if (etag) { - if (ifNoneMatch && ifNoneMatch.includes(etag)) { + if (ifNoneMatch?.includes(etag)) { await reply.header('Cache-Control', CACHE_CONTROL_MUST_REVALIDATE).code(304).send(); } else { void reply.headers({ 'Cache-Control': CACHE_CONTROL_MUST_REVALIDATE, ETag: `"${etag}"` }); @@ -62,6 +82,20 @@ export function setReplyNonCacheable(reply: FastifyReply) { reply.removeHeader('Etag'); } +/** + * Retrieve the blockheight's blockhash so we can use it as the response ETag. + * @param request - Fastify request + * @returns Etag string + */ +async function getBlockHeightEtag(request: FastifyRequest): Promise { + const blockHeights = request.url.split('/').filter(p => BlockHeightParamCType.Check(p)); + return blockHeights?.[0].length + ? await request.server.db + .getBlockHeightETag({ block_height: blockHeights[0] }) + .catch(_ => undefined) // fallback + : undefined; +} + /** * Retrieve the inscriptions's location timestamp as a UNIX epoch so we can use it as the response * ETag. @@ -73,7 +107,7 @@ async function getInscriptionLocationEtag(request: FastifyRequest): Promise { + const result = await this.sql<{ block_hash: string }[]>` + SELECT block_hash + FROM inscriptions_per_block + ORDER BY block_height DESC + LIMIT 1 + `; + return result[0].block_hash; + } + + async getBlockHeightETag(args: { block_height: string }): Promise { + const result = await this.sql<{ block_hash: string }[]>` + SELECT block_hash + FROM inscriptions_per_block + WHERE block_height = ${args.block_height} + `; + return result[0].block_hash; + } + async getInscriptionContent( args: InscriptionIdentifier ): Promise { diff --git a/tests/cache.test.ts b/tests/cache.test.ts index 0d3c3dda..0b123d3a 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -1,7 +1,14 @@ import { buildApiServer } from '../src/api/init'; import { cycleMigrations } from '../src/pg/migrations'; import { PgStore } from '../src/pg/pg-store'; -import { TestChainhookPayloadBuilder, TestFastifyServer, randomHash } from './helpers'; +import { + TestChainhookPayloadBuilder, + TestFastifyServer, + randomHash, + testRevealApply, +} from './helpers'; + +jest.setTimeout(240_000); describe('ETag cache', () => { let db: PgStore; @@ -285,4 +292,172 @@ describe('ETag cache', () => { }); expect(cached.statusCode).toBe(304); }); + + test('recursion /blockheight cache control', async () => { + await db.updateInscriptions(testRevealApply(778_001, { blockHash: randomHash() })); + + let response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockheight', + }); + expect(response.statusCode).toBe(200); + expect(response.headers.etag).toBeDefined(); + let etag = response.headers.etag; + + // Cached response + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockheight', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(304); + + await db.updateInscriptions(testRevealApply(778_002, { blockHash: randomHash() })); + + // Content changed + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockheight', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(200); + expect(response.headers.etag).toBeDefined(); + etag = response.headers.etag; + + // Cached again + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockheight', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(304); + }); + + test('recursion /blockhash cache control', async () => { + await db.updateInscriptions(testRevealApply(778_001, { blockHash: randomHash() })); + + let response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockhash', + }); + expect(response.statusCode).toBe(200); + expect(response.headers.etag).toBeDefined(); + let etag = response.headers.etag; + + // Cached response + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockhash', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(304); + + await db.updateInscriptions(testRevealApply(778_002, { blockHash: randomHash() })); + + // Content changed + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockhash', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(200); + expect(response.headers.etag).toBeDefined(); + etag = response.headers.etag; + + // Cached again + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockhash', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(304); + }); + + test('recursion /blockhash/:blockheight cache control', async () => { + await db.updateInscriptions(testRevealApply(778_001, { blockHash: randomHash() })); + + let response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockhash/778001', + }); + expect(response.statusCode).toBe(200); + expect(response.headers.etag).toBeDefined(); + let etag = response.headers.etag; + + // Cached response + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockhash/778001', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(304); + + await db.updateInscriptions(testRevealApply(778_002, { blockHash: randomHash() })); + + // Content changes, but specific item not modified + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockhash/778001', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(304); + + // New item + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockhash/778002', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(200); + expect(response.headers.etag).toBeDefined(); + etag = response.headers.etag; + + // Cached again + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockhash', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(304); + }); + + test('recursion /blocktime cache control', async () => { + await db.updateInscriptions(testRevealApply(778_001, { blockHash: randomHash() })); + + let response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blocktime', + }); + expect(response.statusCode).toBe(200); + expect(response.headers.etag).toBeDefined(); + let etag = response.headers.etag; + + // Cached response + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blocktime', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(304); + + await db.updateInscriptions(testRevealApply(778_002, { blockHash: randomHash() })); + + // Content changed + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blocktime', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(200); + expect(response.headers.etag).toBeDefined(); + etag = response.headers.etag; + + // Cached again + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blocktime', + headers: { 'if-none-match': etag }, + }); + expect(response.statusCode).toBe(304); + }); });