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 18, 2023
1 parent 522d7f7 commit 389ec23
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 10 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
42 changes: 38 additions & 4 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 { 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,
}

/**
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 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.
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 @@ -312,6 +312,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
178 changes: 177 additions & 1 deletion tests/cache.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
});
});
5 changes: 4 additions & 1 deletion tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

0 comments on commit 389ec23

Please sign in to comment.