diff --git a/src/api/controllers/cache-controller.ts b/src/api/controllers/cache-controller.ts index 91f3f8ad3..2144cf202 100644 --- a/src/api/controllers/cache-controller.ts +++ b/src/api/controllers/cache-controller.ts @@ -8,6 +8,7 @@ import { sha256, } from '@hirosystems/api-toolkit'; import { FastifyReply, FastifyRequest } from 'fastify'; +import { BlockParams } from '../routes/v2/schemas'; /** * Describes a key-value to be saved into a request's locals, representing the current @@ -21,6 +22,8 @@ enum ETagType { mempool = 'mempool', /** ETag based on the status of a single transaction across the mempool or canonical chain. */ transaction = 'transaction', + /** Etag based on the status of a single block */ + block = 'block', /** Etag based on the confirmed balance of a single principal (STX address or contract id) */ principal = 'principal', /** Etag based on `principal` but also including its mempool transactions */ @@ -122,6 +125,13 @@ async function calculateETag( ]; return sha256(elements.join(':')); + case ETagType.block: { + const params = req.params as BlockParams; + const status = await db.getBlockCanonicalStatus(params.height_or_hash); + if (!status) return ETAG_EMPTY; + return `${status.index_block_hash}:${status.canonical}`; + } + case ETagType.principal: case ETagType.principalMempool: const params = req.params as { address?: string; principal?: string }; @@ -180,6 +190,10 @@ export async function handleTransactionCache(request: FastifyRequest, reply: Fas return handleCache(ETagType.transaction, request, reply); } +export async function handleBlockCache(request: FastifyRequest, reply: FastifyReply) { + return handleCache(ETagType.block, request, reply); +} + export async function handlePrincipalCache(request: FastifyRequest, reply: FastifyReply) { return handleCache(ETagType.principal, request, reply); } diff --git a/src/api/routes/v2/blocks.ts b/src/api/routes/v2/blocks.ts index bf647e1b5..28e525c27 100644 --- a/src/api/routes/v2/blocks.ts +++ b/src/api/routes/v2/blocks.ts @@ -1,4 +1,4 @@ -import { handleChainTipCache } from '../../../api/controllers/cache-controller'; +import { handleBlockCache, handleChainTipCache } from '../../../api/controllers/cache-controller'; import { BlockParamsSchema, cleanBlockHeightOrHashParam, parseBlockParam } from './schemas'; import { parseDbNakamotoBlock } from './helpers'; import { InvalidRequestError, NotFoundError } from '../../../errors'; @@ -100,7 +100,7 @@ export const BlockRoutesV2: FastifyPluginAsync< fastify.get( '/:height_or_hash', { - preHandler: handleChainTipCache, + preHandler: handleBlockCache, preValidation: (req, _reply, done) => { cleanBlockHeightOrHashParam(req.params); done(); @@ -129,7 +129,7 @@ export const BlockRoutesV2: FastifyPluginAsync< fastify.get( '/:height_or_hash/transactions', { - preHandler: handleChainTipCache, + preHandler: handleBlockCache, preValidation: (req, _reply, done) => { cleanBlockHeightOrHashParam(req.params); done(); diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index a5392ccce..8045f0ffa 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -97,6 +97,7 @@ import { import * as path from 'path'; import { PgStoreV2 } from './pg-store-v2'; import { Fragment } from 'postgres'; +import { parseBlockParam } from '../api/routes/v2/schemas'; export const MIGRATIONS_DIR = path.join(REPO_DIR, 'migrations'); @@ -4459,4 +4460,25 @@ export class PgStore extends BasePgStore { `; return result.map(r => r.tx_id); } + + /** Returns the `index_block_hash` and canonical status of a single block */ + async getBlockCanonicalStatus( + height_or_hash: string | number + ): Promise<{ index_block_hash: string; canonical: boolean } | undefined> { + const param = parseBlockParam(height_or_hash); + const result = await this.sql<{ index_block_hash: string; canonical: boolean }[]>` + SELECT index_block_hash, canonical + FROM blocks + WHERE + ${ + param.type == 'latest' + ? this.sql`index_block_hash = (SELECT index_block_hash FROM chain_tip)` + : param.type == 'hash' + ? this.sql`index_block_hash = ${param.hash}` + : this.sql`block_height = ${param.height} AND canonical = true` + } + LIMIT 1 + `; + if (result.count) return result[0]; + } } diff --git a/tests/api/cache-control.test.ts b/tests/api/cache-control.test.ts index 54ec4047a..bef714f7f 100644 --- a/tests/api/cache-control.test.ts +++ b/tests/api/cache-control.test.ts @@ -12,12 +12,11 @@ import { startApiServer, ApiServer } from '../../src/api/init'; import { I32_MAX } from '../../src/helpers'; import { TestBlockBuilder, testMempoolTx } from '../utils/test-builders'; import { PgWriteStore } from '../../src/datastore/pg-write-store'; -import { PgSqlClient, bufferToHex } from '@hirosystems/api-toolkit'; +import { bufferToHex } from '@hirosystems/api-toolkit'; import { migrate } from '../utils/test-helpers'; describe('cache-control tests', () => { let db: PgWriteStore; - let client: PgSqlClient; let api: ApiServer; beforeEach(async () => { @@ -27,7 +26,6 @@ describe('cache-control tests', () => { withNotifier: false, skipMigrations: true, }); - client = db.sql; api = await startApiServer({ datastore: db, chainId: ChainID.Testnet }); }); @@ -818,4 +816,104 @@ describe('cache-control tests', () => { expect(request8.status).toBe(304); expect(request8.text).toBe(''); }); + + test('block cache control', async () => { + await db.update( + new TestBlockBuilder({ + block_height: 1, + index_block_hash: '0x01', + parent_index_block_hash: '0x00', + }).build() + ); + await db.update( + new TestBlockBuilder({ + block_height: 2, + index_block_hash: '0x8f652ee1f26bfbffe3cf111994ade25286687b76e6a2f64c33b4632a1f4545ac', + parent_index_block_hash: '0x01', + }).build() + ); + + // Valid latest Etag. + const request1 = await supertest(api.server).get(`/extended/v2/blocks/latest`); + expect(request1.status).toBe(200); + expect(request1.type).toBe('application/json'); + const etag0 = request1.headers['etag']; + + // Same block hash Etag. + const request2 = await supertest(api.server).get( + `/extended/v2/blocks/0x8f652ee1f26bfbffe3cf111994ade25286687b76e6a2f64c33b4632a1f4545ac` + ); + expect(request2.status).toBe(200); + expect(request2.type).toBe('application/json'); + expect(request2.headers['etag']).toEqual(etag0); + + // Same block height Etag. + const request3 = await supertest(api.server).get(`/extended/v2/blocks/2`); + expect(request3.status).toBe(200); + expect(request3.type).toBe('application/json'); + expect(request3.headers['etag']).toEqual(etag0); + + // Cache works with valid ETag. + const request4 = await supertest(api.server) + .get(`/extended/v2/blocks/2`) + .set('If-None-Match', etag0); + expect(request4.status).toBe(304); + expect(request4.text).toBe(''); + + // Add new block. + await db.update( + new TestBlockBuilder({ + block_height: 3, + index_block_hash: '0x03', + parent_index_block_hash: + '0x8f652ee1f26bfbffe3cf111994ade25286687b76e6a2f64c33b4632a1f4545ac', + }).build() + ); + + // Cache still works with same ETag. + const request5 = await supertest(api.server) + .get(`/extended/v2/blocks/2`) + .set('If-None-Match', etag0); + expect(request5.status).toBe(304); + expect(request5.text).toBe(''); + + // Re-org block 2 + await db.update( + new TestBlockBuilder({ + block_height: 2, + index_block_hash: '0x02bb', + parent_index_block_hash: '0x01', + }).build() + ); + await db.update( + new TestBlockBuilder({ + block_height: 3, + index_block_hash: '0x03bb', + parent_index_block_hash: '0x02bb', + }).build() + ); + await db.update( + new TestBlockBuilder({ + block_height: 4, + index_block_hash: '0x04bb', + parent_index_block_hash: '0x03bb', + }).build() + ); + + // Cache is now a miss. + const request6 = await supertest(api.server) + .get(`/extended/v2/blocks/2`) + .set('If-None-Match', etag0); + expect(request6.status).toBe(200); + expect(request6.type).toBe('application/json'); + expect(request6.headers['etag']).not.toEqual(etag0); + const etag1 = request6.headers['etag']; + + // Cache works with new ETag. + const request7 = await supertest(api.server) + .get(`/extended/v2/blocks/2`) + .set('If-None-Match', etag1); + expect(request7.status).toBe(304); + expect(request7.text).toBe(''); + }); });