Skip to content
This repository was archived by the owner on Mar 14, 2025. It is now read-only.

Commit acf5e54

Browse files
committed
fix: add etags to recursion endpoints
1 parent a3c3add commit acf5e54

File tree

4 files changed

+235
-8
lines changed

4 files changed

+235
-8
lines changed

src/api/routes/recursion.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import {
99
BlockTimestampResponse,
1010
NotFoundResponse,
1111
} from '../schemas';
12+
import { handleBlockHashCache, handleBlockHeightCache } from '../util/cache';
1213

1314
const IndexRoutes: FastifyPluginCallback<Record<never, never>, Server, TypeBoxTypeProvider> = (
1415
fastify,
1516
options,
1617
done
1718
) => {
18-
// todo: add blockheight cache? or re-use the inscriptions per block cache (since that would invalidate on gaps as well)
19-
// fastify.addHook('preHandler', handleInscriptionTransfersCache);
19+
fastify.addHook('preHandler', handleBlockHashCache);
2020

2121
fastify.get(
2222
'/blockheight',
@@ -90,8 +90,7 @@ const ShowRoutes: FastifyPluginCallback<Record<never, never>, Server, TypeBoxTyp
9090
options,
9191
done
9292
) => {
93-
// todo: add blockheight cache? or re-use the inscriptions per block cache (since that would invalidate on gaps as well)
94-
// fastify.addHook('preHandler', handleInscriptionCache);
93+
fastify.addHook('preHandler', handleBlockHeightCache);
9594

9695
fastify.get(
9796
'/blockhash/:block_height',

src/api/util/cache.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { FastifyReply, FastifyRequest } from 'fastify';
22
import { logger } from '../../logger';
3-
import { InscriptionIdParamCType, InscriptionNumberParamCType } from '../schemas';
3+
import {
4+
BlockHeightParamCType,
5+
InscriptionIdParamCType,
6+
InscriptionNumberParamCType,
7+
} from '../schemas';
48

59
export enum ETagType {
610
inscriptionTransfers,
711
inscription,
812
inscriptionsPerBlock,
13+
blockHash,
14+
blockHeight,
915
}
1016

1117
/**
@@ -34,6 +40,14 @@ export async function handleInscriptionsPerBlockCache(
3440
return handleCache(ETagType.inscriptionsPerBlock, request, reply);
3541
}
3642

43+
export async function handleBlockHashCache(request: FastifyRequest, reply: FastifyReply) {
44+
return handleCache(ETagType.blockHash, request, reply);
45+
}
46+
47+
export async function handleBlockHeightCache(request: FastifyRequest, reply: FastifyReply) {
48+
return handleCache(ETagType.blockHeight, request, reply);
49+
}
50+
3751
async function handleCache(type: ETagType, request: FastifyRequest, reply: FastifyReply) {
3852
const ifNoneMatch = parseIfNoneMatchHeader(request.headers['if-none-match']);
3953
let etag: string | undefined;
@@ -47,9 +61,15 @@ async function handleCache(type: ETagType, request: FastifyRequest, reply: Fasti
4761
case ETagType.inscriptionsPerBlock:
4862
etag = await request.server.db.getInscriptionsPerBlockETag();
4963
break;
64+
case ETagType.blockHash:
65+
etag = await request.server.db.getBlockHashETag();
66+
break;
67+
case ETagType.blockHeight:
68+
etag = await getBlockHeightEtag(request);
69+
break;
5070
}
5171
if (etag) {
52-
if (ifNoneMatch && ifNoneMatch.includes(etag)) {
72+
if (ifNoneMatch?.includes(etag)) {
5373
await reply.header('Cache-Control', CACHE_CONTROL_MUST_REVALIDATE).code(304).send();
5474
} else {
5575
void reply.headers({ 'Cache-Control': CACHE_CONTROL_MUST_REVALIDATE, ETag: `"${etag}"` });
@@ -62,6 +82,20 @@ export function setReplyNonCacheable(reply: FastifyReply) {
6282
reply.removeHeader('Etag');
6383
}
6484

85+
/**
86+
* Retrieve the blockheight's blockhash so we can use it as the response ETag.
87+
* @param request - Fastify request
88+
* @returns Etag string
89+
*/
90+
async function getBlockHeightEtag(request: FastifyRequest): Promise<string | undefined> {
91+
const blockHeights = request.url.split('/').filter(p => BlockHeightParamCType.Check(p));
92+
return blockHeights?.[0].length
93+
? await request.server.db
94+
.getBlockHeightETag({ block_height: blockHeights[0] })
95+
.catch(_ => undefined) // fallback
96+
: undefined;
97+
}
98+
6599
/**
66100
* Retrieve the inscriptions's location timestamp as a UNIX epoch so we can use it as the response
67101
* ETag.
@@ -73,7 +107,7 @@ async function getInscriptionLocationEtag(request: FastifyRequest): Promise<stri
73107
const components = request.url.split('/');
74108
do {
75109
const lastElement = components.pop();
76-
if (lastElement && lastElement.length) {
110+
if (lastElement?.length) {
77111
if (InscriptionIdParamCType.Check(lastElement)) {
78112
return await request.server.db.getInscriptionETag({ genesis_id: lastElement });
79113
} else if (InscriptionNumberParamCType.Check(parseInt(lastElement))) {

src/pg/pg-store.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,25 @@ export class PgStore extends BasePgStore {
299299
return `${result[0].block_hash}:${result[0].inscription_count}`;
300300
}
301301

302+
async getBlockHashETag(): Promise<string> {
303+
const result = await this.sql<{ block_hash: string }[]>`
304+
SELECT block_hash
305+
FROM inscriptions_per_block
306+
ORDER BY block_height DESC
307+
LIMIT 1
308+
`;
309+
return result[0].block_hash;
310+
}
311+
312+
async getBlockHeightETag(args: { block_height: string }): Promise<string> {
313+
const result = await this.sql<{ block_hash: string }[]>`
314+
SELECT block_hash
315+
FROM inscriptions_per_block
316+
WHERE block_height = ${args.block_height}
317+
`;
318+
return result[0].block_hash;
319+
}
320+
302321
async getInscriptionContent(
303322
args: InscriptionIdentifier
304323
): Promise<DbInscriptionContent | undefined> {

tests/cache.test.ts

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { buildApiServer } from '../src/api/init';
22
import { cycleMigrations } from '../src/pg/migrations';
33
import { PgStore } from '../src/pg/pg-store';
4-
import { TestChainhookPayloadBuilder, TestFastifyServer, randomHash } from './helpers';
4+
import {
5+
TestChainhookPayloadBuilder,
6+
TestFastifyServer,
7+
randomHash,
8+
testRevealApply,
9+
} from './helpers';
10+
11+
jest.setTimeout(240_000);
512

613
describe('ETag cache', () => {
714
let db: PgStore;
@@ -285,4 +292,172 @@ describe('ETag cache', () => {
285292
});
286293
expect(cached.statusCode).toBe(304);
287294
});
295+
296+
test('recursion /blockheight cache control', async () => {
297+
await db.updateInscriptions(testRevealApply(778_001, { blockHash: randomHash() }));
298+
299+
let response = await fastify.inject({
300+
method: 'GET',
301+
url: '/ordinals/v1/blockheight',
302+
});
303+
expect(response.statusCode).toBe(200);
304+
expect(response.headers.etag).toBeDefined();
305+
let etag = response.headers.etag;
306+
307+
// Cached response
308+
response = await fastify.inject({
309+
method: 'GET',
310+
url: '/ordinals/v1/blockheight',
311+
headers: { 'if-none-match': etag },
312+
});
313+
expect(response.statusCode).toBe(304);
314+
315+
await db.updateInscriptions(testRevealApply(778_002, { blockHash: randomHash() }));
316+
317+
// Content changed
318+
response = await fastify.inject({
319+
method: 'GET',
320+
url: '/ordinals/v1/blockheight',
321+
headers: { 'if-none-match': etag },
322+
});
323+
expect(response.statusCode).toBe(200);
324+
expect(response.headers.etag).toBeDefined();
325+
etag = response.headers.etag;
326+
327+
// Cached again
328+
response = await fastify.inject({
329+
method: 'GET',
330+
url: '/ordinals/v1/blockheight',
331+
headers: { 'if-none-match': etag },
332+
});
333+
expect(response.statusCode).toBe(304);
334+
});
335+
336+
test('recursion /blockhash cache control', async () => {
337+
await db.updateInscriptions(testRevealApply(778_001, { blockHash: randomHash() }));
338+
339+
let response = await fastify.inject({
340+
method: 'GET',
341+
url: '/ordinals/v1/blockhash',
342+
});
343+
expect(response.statusCode).toBe(200);
344+
expect(response.headers.etag).toBeDefined();
345+
let etag = response.headers.etag;
346+
347+
// Cached response
348+
response = await fastify.inject({
349+
method: 'GET',
350+
url: '/ordinals/v1/blockhash',
351+
headers: { 'if-none-match': etag },
352+
});
353+
expect(response.statusCode).toBe(304);
354+
355+
await db.updateInscriptions(testRevealApply(778_002, { blockHash: randomHash() }));
356+
357+
// Content changed
358+
response = await fastify.inject({
359+
method: 'GET',
360+
url: '/ordinals/v1/blockhash',
361+
headers: { 'if-none-match': etag },
362+
});
363+
expect(response.statusCode).toBe(200);
364+
expect(response.headers.etag).toBeDefined();
365+
etag = response.headers.etag;
366+
367+
// Cached again
368+
response = await fastify.inject({
369+
method: 'GET',
370+
url: '/ordinals/v1/blockhash',
371+
headers: { 'if-none-match': etag },
372+
});
373+
expect(response.statusCode).toBe(304);
374+
});
375+
376+
test('recursion /blockhash/:blockheight cache control', async () => {
377+
await db.updateInscriptions(testRevealApply(778_001, { blockHash: randomHash() }));
378+
379+
let response = await fastify.inject({
380+
method: 'GET',
381+
url: '/ordinals/v1/blockhash/778001',
382+
});
383+
expect(response.statusCode).toBe(200);
384+
expect(response.headers.etag).toBeDefined();
385+
let etag = response.headers.etag;
386+
387+
// Cached response
388+
response = await fastify.inject({
389+
method: 'GET',
390+
url: '/ordinals/v1/blockhash/778001',
391+
headers: { 'if-none-match': etag },
392+
});
393+
expect(response.statusCode).toBe(304);
394+
395+
await db.updateInscriptions(testRevealApply(778_002, { blockHash: randomHash() }));
396+
397+
// Content changes, but specific item not modified
398+
response = await fastify.inject({
399+
method: 'GET',
400+
url: '/ordinals/v1/blockhash/778001',
401+
headers: { 'if-none-match': etag },
402+
});
403+
expect(response.statusCode).toBe(304);
404+
405+
// New item
406+
response = await fastify.inject({
407+
method: 'GET',
408+
url: '/ordinals/v1/blockhash/778002',
409+
headers: { 'if-none-match': etag },
410+
});
411+
expect(response.statusCode).toBe(200);
412+
expect(response.headers.etag).toBeDefined();
413+
etag = response.headers.etag;
414+
415+
// Cached again
416+
response = await fastify.inject({
417+
method: 'GET',
418+
url: '/ordinals/v1/blockhash',
419+
headers: { 'if-none-match': etag },
420+
});
421+
expect(response.statusCode).toBe(304);
422+
});
423+
424+
test('recursion /blocktime cache control', async () => {
425+
await db.updateInscriptions(testRevealApply(778_001, { blockHash: randomHash() }));
426+
427+
let response = await fastify.inject({
428+
method: 'GET',
429+
url: '/ordinals/v1/blocktime',
430+
});
431+
expect(response.statusCode).toBe(200);
432+
expect(response.headers.etag).toBeDefined();
433+
let etag = response.headers.etag;
434+
435+
// Cached response
436+
response = await fastify.inject({
437+
method: 'GET',
438+
url: '/ordinals/v1/blocktime',
439+
headers: { 'if-none-match': etag },
440+
});
441+
expect(response.statusCode).toBe(304);
442+
443+
await db.updateInscriptions(testRevealApply(778_002, { blockHash: randomHash() }));
444+
445+
// Content changed
446+
response = await fastify.inject({
447+
method: 'GET',
448+
url: '/ordinals/v1/blocktime',
449+
headers: { 'if-none-match': etag },
450+
});
451+
expect(response.statusCode).toBe(200);
452+
expect(response.headers.etag).toBeDefined();
453+
etag = response.headers.etag;
454+
455+
// Cached again
456+
response = await fastify.inject({
457+
method: 'GET',
458+
url: '/ordinals/v1/blocktime',
459+
headers: { 'if-none-match': etag },
460+
});
461+
expect(response.statusCode).toBe(304);
462+
});
288463
});

0 commit comments

Comments
 (0)