Skip to content

Commit

Permalink
fix: add etags to recursion endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
janniks committed Jul 17, 2023
1 parent a3c3add commit acf5e54
Show file tree
Hide file tree
Showing 4 changed files with 235 additions and 8 deletions.
7 changes: 3 additions & 4 deletions src/api/routes/recursion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import {
BlockTimestampResponse,
NotFoundResponse,
} from '../schemas';
import { handleBlockHashCache, handleBlockHeightCache } from '../util/cache';

const IndexRoutes: FastifyPluginCallback<Record<never, never>, 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',
Expand Down Expand Up @@ -90,8 +90,7 @@ const ShowRoutes: FastifyPluginCallback<Record<never, never>, 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',
Expand Down
40 changes: 37 additions & 3 deletions src/api/util/cache.ts
Original file line number Diff line number Diff line change
@@ -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,
}

/**
Expand Down Expand Up @@ -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;
Expand All @@ -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}"` });
Expand All @@ -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<string | undefined> {
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.
Expand All @@ -73,7 +107,7 @@ async function getInscriptionLocationEtag(request: FastifyRequest): Promise<stri
const components = request.url.split('/');
do {
const lastElement = components.pop();
if (lastElement && lastElement.length) {
if (lastElement?.length) {
if (InscriptionIdParamCType.Check(lastElement)) {
return await request.server.db.getInscriptionETag({ genesis_id: lastElement });
} else if (InscriptionNumberParamCType.Check(parseInt(lastElement))) {
Expand Down
19 changes: 19 additions & 0 deletions src/pg/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,25 @@ export class PgStore extends BasePgStore {
return `${result[0].block_hash}:${result[0].inscription_count}`;
}

async getBlockHashETag(): Promise<string> {
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<string> {
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<DbInscriptionContent | undefined> {
Expand Down
177 changes: 176 additions & 1 deletion tests/cache.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
});
});

0 comments on commit acf5e54

Please sign in to comment.