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 0b367c51..eda129df 100644 --- a/src/api/util/cache.ts +++ b/src/api/util/cache.ts @@ -1,11 +1,17 @@ -import { FastifyReply, FastifyRequest } from 'fastify'; -import { InscriptionIdParamCType, InscriptionNumberParamCType } from '../schemas'; import { logger } from '@hirosystems/api-toolkit'; +import { FastifyReply, FastifyRequest } from 'fastify'; +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 blockHeightParam = request.url.split('/').find(p => BlockHeightParamCType.Check(p)); + return blockHeightParam + ? await request.server.db + .getBlockHeightETag({ block_height: blockHeightParam }) + .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 c1f30bf2..dfc3f889 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -1,7 +1,12 @@ import { cycleMigrations } from '@hirosystems/api-toolkit'; import { buildApiServer } from '../src/api/init'; import { MIGRATIONS_DIR, PgStore } from '../src/pg/pg-store'; -import { TestChainhookPayloadBuilder, TestFastifyServer, randomHash } from './helpers'; +import { + TestChainhookPayloadBuilder, + TestFastifyServer, + randomHash, + testRevealApply, +} from './helpers'; describe('ETag cache', () => { let db: PgStore; @@ -280,6 +285,9 @@ describe('ETag cache', () => { ordinal_offset: 0, satpoint_post_inscription: '9f4a9b73b0713c5da01c0a47f97c6c001af9028d6bdd9e264dfacbc4e6790201:0:0', + inscription_input_index: 0, + transfers_pre_inscription: 0, + tx_index: 0, }) .build(); await db.updateInscriptions(block1); @@ -301,4 +309,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); + }); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index fa067033..c0d47cd1 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -135,10 +135,13 @@ export function testRevealApply( inscription_id: `${randomHex}i0`, inscription_output_value: 10000, inscriber_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + satpoint_post_inscription: `${randomHex}:0:0`, ordinal_number: Math.floor(Math.random() * 1_000_000), ordinal_block_height: Math.floor(Math.random() * 777_000), ordinal_offset: 0, - satpoint_post_inscription: `${randomHex}:0:0`, + inscription_input_index: 0, + transfers_pre_inscription: 0, + tx_index: 0, }) .build(); }