Skip to content

Commit 66e6800

Browse files
authored
feat: add block etag (#2103)
1 parent 2370c21 commit 66e6800

File tree

4 files changed

+140
-6
lines changed

4 files changed

+140
-6
lines changed

src/api/controllers/cache-controller.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
sha256,
99
} from '@hirosystems/api-toolkit';
1010
import { FastifyReply, FastifyRequest } from 'fastify';
11+
import { BlockParams } from '../routes/v2/schemas';
1112

1213
/**
1314
* Describes a key-value to be saved into a request's locals, representing the current
@@ -21,6 +22,8 @@ enum ETagType {
2122
mempool = 'mempool',
2223
/** ETag based on the status of a single transaction across the mempool or canonical chain. */
2324
transaction = 'transaction',
25+
/** Etag based on the status of a single block */
26+
block = 'block',
2427
/** Etag based on the confirmed balance of a single principal (STX address or contract id) */
2528
principal = 'principal',
2629
/** Etag based on `principal` but also including its mempool transactions */
@@ -122,6 +125,13 @@ async function calculateETag(
122125
];
123126
return sha256(elements.join(':'));
124127

128+
case ETagType.block: {
129+
const params = req.params as BlockParams;
130+
const status = await db.getBlockCanonicalStatus(params.height_or_hash);
131+
if (!status) return ETAG_EMPTY;
132+
return `${status.index_block_hash}:${status.canonical}`;
133+
}
134+
125135
case ETagType.principal:
126136
case ETagType.principalMempool:
127137
const params = req.params as { address?: string; principal?: string };
@@ -180,6 +190,10 @@ export async function handleTransactionCache(request: FastifyRequest, reply: Fas
180190
return handleCache(ETagType.transaction, request, reply);
181191
}
182192

193+
export async function handleBlockCache(request: FastifyRequest, reply: FastifyReply) {
194+
return handleCache(ETagType.block, request, reply);
195+
}
196+
183197
export async function handlePrincipalCache(request: FastifyRequest, reply: FastifyReply) {
184198
return handleCache(ETagType.principal, request, reply);
185199
}

src/api/routes/v2/blocks.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { handleChainTipCache } from '../../../api/controllers/cache-controller';
1+
import { handleBlockCache, handleChainTipCache } from '../../../api/controllers/cache-controller';
22
import { BlockParamsSchema, cleanBlockHeightOrHashParam, parseBlockParam } from './schemas';
33
import { parseDbNakamotoBlock } from './helpers';
44
import { InvalidRequestError, NotFoundError } from '../../../errors';
@@ -100,7 +100,7 @@ export const BlockRoutesV2: FastifyPluginAsync<
100100
fastify.get(
101101
'/:height_or_hash',
102102
{
103-
preHandler: handleChainTipCache,
103+
preHandler: handleBlockCache,
104104
preValidation: (req, _reply, done) => {
105105
cleanBlockHeightOrHashParam(req.params);
106106
done();
@@ -129,7 +129,7 @@ export const BlockRoutesV2: FastifyPluginAsync<
129129
fastify.get(
130130
'/:height_or_hash/transactions',
131131
{
132-
preHandler: handleChainTipCache,
132+
preHandler: handleBlockCache,
133133
preValidation: (req, _reply, done) => {
134134
cleanBlockHeightOrHashParam(req.params);
135135
done();

src/datastore/pg-store.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import {
9797
import * as path from 'path';
9898
import { PgStoreV2 } from './pg-store-v2';
9999
import { Fragment } from 'postgres';
100+
import { parseBlockParam } from '../api/routes/v2/schemas';
100101

101102
export const MIGRATIONS_DIR = path.join(REPO_DIR, 'migrations');
102103

@@ -4459,4 +4460,25 @@ export class PgStore extends BasePgStore {
44594460
`;
44604461
return result.map(r => r.tx_id);
44614462
}
4463+
4464+
/** Returns the `index_block_hash` and canonical status of a single block */
4465+
async getBlockCanonicalStatus(
4466+
height_or_hash: string | number
4467+
): Promise<{ index_block_hash: string; canonical: boolean } | undefined> {
4468+
const param = parseBlockParam(height_or_hash);
4469+
const result = await this.sql<{ index_block_hash: string; canonical: boolean }[]>`
4470+
SELECT index_block_hash, canonical
4471+
FROM blocks
4472+
WHERE
4473+
${
4474+
param.type == 'latest'
4475+
? this.sql`index_block_hash = (SELECT index_block_hash FROM chain_tip)`
4476+
: param.type == 'hash'
4477+
? this.sql`index_block_hash = ${param.hash}`
4478+
: this.sql`block_height = ${param.height} AND canonical = true`
4479+
}
4480+
LIMIT 1
4481+
`;
4482+
if (result.count) return result[0];
4483+
}
44624484
}

tests/api/cache-control.test.ts

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,11 @@ import { startApiServer, ApiServer } from '../../src/api/init';
1212
import { I32_MAX } from '../../src/helpers';
1313
import { TestBlockBuilder, testMempoolTx } from '../utils/test-builders';
1414
import { PgWriteStore } from '../../src/datastore/pg-write-store';
15-
import { PgSqlClient, bufferToHex } from '@hirosystems/api-toolkit';
15+
import { bufferToHex } from '@hirosystems/api-toolkit';
1616
import { migrate } from '../utils/test-helpers';
1717

1818
describe('cache-control tests', () => {
1919
let db: PgWriteStore;
20-
let client: PgSqlClient;
2120
let api: ApiServer;
2221

2322
beforeEach(async () => {
@@ -27,7 +26,6 @@ describe('cache-control tests', () => {
2726
withNotifier: false,
2827
skipMigrations: true,
2928
});
30-
client = db.sql;
3129
api = await startApiServer({ datastore: db, chainId: ChainID.Testnet });
3230
});
3331

@@ -818,4 +816,104 @@ describe('cache-control tests', () => {
818816
expect(request8.status).toBe(304);
819817
expect(request8.text).toBe('');
820818
});
819+
820+
test('block cache control', async () => {
821+
await db.update(
822+
new TestBlockBuilder({
823+
block_height: 1,
824+
index_block_hash: '0x01',
825+
parent_index_block_hash: '0x00',
826+
}).build()
827+
);
828+
await db.update(
829+
new TestBlockBuilder({
830+
block_height: 2,
831+
index_block_hash: '0x8f652ee1f26bfbffe3cf111994ade25286687b76e6a2f64c33b4632a1f4545ac',
832+
parent_index_block_hash: '0x01',
833+
}).build()
834+
);
835+
836+
// Valid latest Etag.
837+
const request1 = await supertest(api.server).get(`/extended/v2/blocks/latest`);
838+
expect(request1.status).toBe(200);
839+
expect(request1.type).toBe('application/json');
840+
const etag0 = request1.headers['etag'];
841+
842+
// Same block hash Etag.
843+
const request2 = await supertest(api.server).get(
844+
`/extended/v2/blocks/0x8f652ee1f26bfbffe3cf111994ade25286687b76e6a2f64c33b4632a1f4545ac`
845+
);
846+
expect(request2.status).toBe(200);
847+
expect(request2.type).toBe('application/json');
848+
expect(request2.headers['etag']).toEqual(etag0);
849+
850+
// Same block height Etag.
851+
const request3 = await supertest(api.server).get(`/extended/v2/blocks/2`);
852+
expect(request3.status).toBe(200);
853+
expect(request3.type).toBe('application/json');
854+
expect(request3.headers['etag']).toEqual(etag0);
855+
856+
// Cache works with valid ETag.
857+
const request4 = await supertest(api.server)
858+
.get(`/extended/v2/blocks/2`)
859+
.set('If-None-Match', etag0);
860+
expect(request4.status).toBe(304);
861+
expect(request4.text).toBe('');
862+
863+
// Add new block.
864+
await db.update(
865+
new TestBlockBuilder({
866+
block_height: 3,
867+
index_block_hash: '0x03',
868+
parent_index_block_hash:
869+
'0x8f652ee1f26bfbffe3cf111994ade25286687b76e6a2f64c33b4632a1f4545ac',
870+
}).build()
871+
);
872+
873+
// Cache still works with same ETag.
874+
const request5 = await supertest(api.server)
875+
.get(`/extended/v2/blocks/2`)
876+
.set('If-None-Match', etag0);
877+
expect(request5.status).toBe(304);
878+
expect(request5.text).toBe('');
879+
880+
// Re-org block 2
881+
await db.update(
882+
new TestBlockBuilder({
883+
block_height: 2,
884+
index_block_hash: '0x02bb',
885+
parent_index_block_hash: '0x01',
886+
}).build()
887+
);
888+
await db.update(
889+
new TestBlockBuilder({
890+
block_height: 3,
891+
index_block_hash: '0x03bb',
892+
parent_index_block_hash: '0x02bb',
893+
}).build()
894+
);
895+
await db.update(
896+
new TestBlockBuilder({
897+
block_height: 4,
898+
index_block_hash: '0x04bb',
899+
parent_index_block_hash: '0x03bb',
900+
}).build()
901+
);
902+
903+
// Cache is now a miss.
904+
const request6 = await supertest(api.server)
905+
.get(`/extended/v2/blocks/2`)
906+
.set('If-None-Match', etag0);
907+
expect(request6.status).toBe(200);
908+
expect(request6.type).toBe('application/json');
909+
expect(request6.headers['etag']).not.toEqual(etag0);
910+
const etag1 = request6.headers['etag'];
911+
912+
// Cache works with new ETag.
913+
const request7 = await supertest(api.server)
914+
.get(`/extended/v2/blocks/2`)
915+
.set('If-None-Match', etag1);
916+
expect(request7.status).toBe(304);
917+
expect(request7.text).toBe('');
918+
});
821919
});

0 commit comments

Comments
 (0)