Skip to content

Commit 8d5ca2c

Browse files
authored
feat: add cache control to /extended/v1/tx/:tx_id (#1229)
* feat: add transaction etag type * test: cache behavior * feat: include index_block_hash in etag * feat: add microblock_hash to etag * chore: add limit 1 to queries
1 parent 30371f9 commit 8d5ca2c

File tree

6 files changed

+235
-17
lines changed

6 files changed

+235
-17
lines changed

src/api/controllers/cache-controller.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { RequestHandler, Request, Response } from 'express';
22
import * as prom from 'prom-client';
3-
import { logger } from '../../helpers';
3+
import { bufferToHexPrefixString, logger, normalizeHashString } from '../../helpers';
44
import { DataStore } from '../../datastore/common';
55
import { asyncHandler } from '../async-handler';
66

@@ -24,6 +24,8 @@ export enum ETagType {
2424
chainTip = 'chain_tip',
2525
/** ETag based on a digest of all pending mempool `tx_id`s. */
2626
mempool = 'mempool',
27+
/** ETag based on the status of a single transaction across the mempool or canonical chain. */
28+
transaction = 'transaction',
2729
}
2830

2931
/** Value that means the ETag did get calculated but it is empty. */
@@ -166,7 +168,7 @@ async function checkETagCacheOK(
166168
etagType: ETagType
167169
): Promise<ETag | undefined | typeof CACHE_OK> {
168170
const metrics = getETagMetrics();
169-
const etag = await calculateETag(db, etagType);
171+
const etag = await calculateETag(db, etagType, req);
170172
if (!etag || etag === ETAG_EMPTY) {
171173
return;
172174
}
@@ -240,7 +242,11 @@ export function getETagCacheHandler(
240242
return requestHandler;
241243
}
242244

243-
async function calculateETag(db: DataStore, etagType: ETagType): Promise<ETag | undefined> {
245+
async function calculateETag(
246+
db: DataStore,
247+
etagType: ETagType,
248+
req: Request
249+
): Promise<ETag | undefined> {
244250
switch (etagType) {
245251
case ETagType.chainTip:
246252
const chainTip = await db.getUnanchoredChainTip();
@@ -261,5 +267,23 @@ async function calculateETag(db: DataStore, etagType: ETagType): Promise<ETag |
261267
return ETAG_EMPTY;
262268
}
263269
return digest.result.digest;
270+
271+
case ETagType.transaction:
272+
const { tx_id } = req.params;
273+
const normalizedTxId = normalizeHashString(tx_id);
274+
if (normalizedTxId === false) {
275+
return ETAG_EMPTY;
276+
}
277+
const status = await db.getTxStatus(normalizedTxId);
278+
if (!status.found) {
279+
return ETAG_EMPTY;
280+
}
281+
const elements: string[] = [
282+
normalizedTxId,
283+
bufferToHexPrefixString(status.result.index_block_hash),
284+
bufferToHexPrefixString(status.result.microblock_hash),
285+
status.result.status.toString(),
286+
];
287+
return elements.join(':');
264288
}
265289
}

src/api/controllers/db-controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export function getTxStatusString(
159159
case DbTxStatus.DroppedTooExpensive:
160160
return 'dropped_too_expensive';
161161
case DbTxStatus.DroppedStaleGarbageCollect:
162+
case DbTxStatus.DroppedApiGarbageCollect:
162163
return 'dropped_stale_garbage_collect';
163164
default:
164165
throw new Error(`Unexpected DbTxStatus: ${txStatus}`);

src/api/routes/tx.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export function createTxRouter(db: DataStore): express.Router {
6767

6868
const cacheHandler = getETagCacheHandler(db);
6969
const mempoolCacheHandler = getETagCacheHandler(db, ETagType.mempool);
70+
const txCacheHandler = getETagCacheHandler(db, ETagType.transaction);
7071

7172
router.get(
7273
'/',
@@ -285,9 +286,9 @@ export function createTxRouter(db: DataStore): express.Router {
285286
})
286287
);
287288

288-
// TODO: Add cache headers. Impossible right now since this tx might be from a block or from the mempool.
289289
router.get(
290290
'/:tx_id',
291+
txCacheHandler,
291292
asyncHandler(async (req, res, next) => {
292293
const { tx_id } = req.params;
293294
if (!has0xPrefix(tx_id)) {
@@ -309,20 +310,14 @@ export function createTxRouter(db: DataStore): express.Router {
309310
res.status(404).json({ error: `could not find transaction by ID ${tx_id}` });
310311
return;
311312
}
312-
// TODO: this validation needs fixed now that the mempool-tx and mined-tx types no longer overlap
313-
/*
314-
const schemaPath = require.resolve(
315-
'@stacks/stacks-blockchain-api-types/entities/transactions/transaction.schema.json'
316-
);
317-
await validate(schemaPath, txQuery.result);
318-
*/
313+
setETagCacheHeaders(res, ETagType.transaction);
319314
res.json(txQuery.result);
320315
})
321316
);
322317

323-
// TODO: Add cache headers. Impossible right now since this tx might be from a block or from the mempool.
324318
router.get(
325319
'/:tx_id/raw',
320+
txCacheHandler,
326321
asyncHandler(async (req, res) => {
327322
const { tx_id } = req.params;
328323
if (!has0xPrefix(tx_id)) {
@@ -336,6 +331,7 @@ export function createTxRouter(db: DataStore): express.Router {
336331
const response: GetRawTransactionResult = {
337332
raw_tx: bufferToHexPrefixString(rawTxQuery.result.raw_tx),
338333
};
334+
setETagCacheHeaders(res, ETagType.transaction);
339335
res.json(response);
340336
} else {
341337
res.status(404).json({ error: `could not find transaction by ID ${tx_id}` });

src/datastore/common.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,7 @@ import {
1313
TxPayloadTypeID,
1414
PostConditionAuthFlag,
1515
} from 'stacks-encoding-native-js';
16-
import {
17-
AddressTokenOfferingLocked,
18-
MempoolTransaction,
19-
TransactionType,
20-
} from '@stacks/stacks-blockchain-api-types';
16+
import { AddressTokenOfferingLocked, TransactionType } from '@stacks/stacks-blockchain-api-types';
2117
import { getTxSenderAddress } from '../event-stream/reader';
2218
import { RawTxQueryResult } from './postgres-store';
2319
import { ChainID, ClarityAbi } from '@stacks/transactions';
@@ -615,6 +611,12 @@ export interface DbChainTip {
615611
microblockSequence?: number;
616612
}
617613

614+
export interface DbTxGlobalStatus {
615+
status: DbTxStatus;
616+
index_block_hash: Buffer;
617+
microblock_hash: Buffer;
618+
}
619+
618620
export interface DataStore extends DataStoreEventEmitter {
619621
storeRawEventRequest(eventPath: string, payload: string): Promise<void>;
620622
getSubdomainResolver(name: { name: string }): Promise<FoundOrNot<string>>;
@@ -865,6 +867,8 @@ export interface DataStore extends DataStoreEventEmitter {
865867

866868
getRawTx(txId: string): Promise<FoundOrNot<RawTxQueryResult>>;
867869

870+
getTxStatus(txId: string): Promise<FoundOrNot<DbTxGlobalStatus>>;
871+
868872
/**
869873
* Returns a list of NFTs owned by the given principal filtered by optional `asset_identifiers`,
870874
* including optional transaction metadata.

src/datastore/postgres-store.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,15 @@ import {
9797
NftHoldingInfoWithTxMetadata,
9898
NftEventWithTxMetadata,
9999
DbAssetEventTypeId,
100+
DbTxGlobalStatus,
100101
} from './common';
101102
import {
102103
AddressTokenOfferingLocked,
103104
TransactionType,
104105
AddressUnlockSchedule,
105106
Block,
107+
MempoolTransactionStatus,
108+
TransactionStatus,
106109
} from '@stacks/stacks-blockchain-api-types';
107110
import { getTxTypeId } from '../api/controllers/db-controller';
108111
import { isProcessableTokenMetadata } from '../token-metadata/helpers';
@@ -4053,6 +4056,46 @@ export class PgDataStore
40534056
});
40544057
}
40554058

4059+
async getTxStatus(txId: string): Promise<FoundOrNot<DbTxGlobalStatus>> {
4060+
return this.queryTx(async client => {
4061+
const chainResult = await client.query<DbTxGlobalStatus>(
4062+
`SELECT status, index_block_hash, microblock_hash
4063+
FROM txs
4064+
WHERE tx_id = $1 AND canonical = TRUE AND microblock_canonical = TRUE
4065+
LIMIT 1`,
4066+
[hexToBuffer(txId)]
4067+
);
4068+
if (chainResult.rowCount > 0) {
4069+
return {
4070+
found: true,
4071+
result: {
4072+
status: chainResult.rows[0].status,
4073+
index_block_hash: chainResult.rows[0].index_block_hash,
4074+
microblock_hash: chainResult.rows[0].microblock_hash,
4075+
},
4076+
};
4077+
}
4078+
const mempoolResult = await client.query<{ status: number }>(
4079+
`SELECT status
4080+
FROM mempool_txs
4081+
WHERE tx_id = $1
4082+
LIMIT 1`,
4083+
[hexToBuffer(txId)]
4084+
);
4085+
if (mempoolResult.rowCount > 0) {
4086+
return {
4087+
found: true,
4088+
result: {
4089+
status: mempoolResult.rows[0].status,
4090+
index_block_hash: Buffer.from([]),
4091+
microblock_hash: Buffer.from([]),
4092+
},
4093+
};
4094+
}
4095+
return { found: false } as const;
4096+
});
4097+
}
4098+
40564099
async getMaxBlockHeight(
40574100
client: ClientBase,
40584101
{ includeUnanchored }: { includeUnanchored: boolean }

src/tests/cache-control-tests.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,156 @@ describe('cache-control tests', () => {
425425
expect(request8.headers['etag']).toBeUndefined();
426426
});
427427

428+
test('transaction cache control', async () => {
429+
const txId1 = '0x0153a41ed24a0e1d32f66ea98338df09f942571ca66359e28bdca79ccd0305cf';
430+
const txId2 = '0xfb4bfc274007825dfd2d8f6c3f429407016779e9954775f82129108282d4c4ce';
431+
432+
const block1 = new TestBlockBuilder({
433+
block_height: 1,
434+
index_block_hash: '0x01',
435+
})
436+
.addTx()
437+
.build();
438+
await db.update(block1);
439+
440+
// No tx yet.
441+
const request1 = await supertest(api.server).get(`/extended/v1/tx/${txId1}`);
442+
expect(request1.status).toBe(404);
443+
expect(request1.type).toBe('application/json');
444+
445+
// Add mempool tx.
446+
const mempoolTx1 = testMempoolTx({ tx_id: txId1 });
447+
await db.updateMempoolTxs({ mempoolTxs: [mempoolTx1] });
448+
449+
// Valid mempool ETag.
450+
const request2 = await supertest(api.server).get(`/extended/v1/tx/${txId1}`);
451+
expect(request2.status).toBe(200);
452+
expect(request2.type).toBe('application/json');
453+
expect(request2.headers['etag']).toBeTruthy();
454+
const etag1 = request2.headers['etag'];
455+
456+
// Cache works with valid ETag.
457+
const request3 = await supertest(api.server)
458+
.get(`/extended/v1/tx/${txId1}`)
459+
.set('If-None-Match', etag1);
460+
expect(request3.status).toBe(304);
461+
expect(request3.text).toBe('');
462+
463+
// Mine the same tx into a block
464+
const block2 = new TestBlockBuilder({
465+
block_height: 2,
466+
index_block_hash: '0x02',
467+
parent_index_block_hash: '0x01',
468+
})
469+
.addTx({ tx_id: txId1 })
470+
.build();
471+
await db.update(block2);
472+
473+
// Cache no longer works with mempool ETag but we get updated ETag.
474+
const request4 = await supertest(api.server)
475+
.get(`/extended/v1/tx/${txId1}`)
476+
.set('If-None-Match', etag1);
477+
expect(request4.status).toBe(200);
478+
expect(request4.headers['etag']).toBeTruthy();
479+
const etag2 = request4.headers['etag'];
480+
481+
// Cache works with new ETag.
482+
const request5 = await supertest(api.server)
483+
.get(`/extended/v1/tx/${txId1}`)
484+
.set('If-None-Match', etag2);
485+
expect(request5.status).toBe(304);
486+
expect(request5.text).toBe('');
487+
488+
// No tx #2 yet.
489+
const request6 = await supertest(api.server).get(`/extended/v1/tx/${txId2}`);
490+
expect(request6.status).toBe(404);
491+
expect(request6.type).toBe('application/json');
492+
493+
// Tx #2 directly into a block
494+
const block3 = new TestBlockBuilder({
495+
block_height: 3,
496+
index_block_hash: '0x03',
497+
parent_index_block_hash: '0x02',
498+
})
499+
.addTx({ tx_id: txId2 })
500+
.build();
501+
await db.update(block3);
502+
503+
// Valid block ETag.
504+
const request7 = await supertest(api.server).get(`/extended/v1/tx/${txId2}`);
505+
expect(request7.status).toBe(200);
506+
expect(request7.type).toBe('application/json');
507+
expect(request7.headers['etag']).toBeTruthy();
508+
const etag3 = request7.headers['etag'];
509+
510+
// Cache works with valid ETag.
511+
const request8 = await supertest(api.server)
512+
.get(`/extended/v1/tx/${txId2}`)
513+
.set('If-None-Match', etag3);
514+
expect(request8.status).toBe(304);
515+
expect(request8.text).toBe('');
516+
517+
// Oops, new blocks came, all txs before are non-canonical
518+
const block2a = new TestBlockBuilder({
519+
block_height: 2,
520+
index_block_hash: '0x02ff',
521+
parent_index_block_hash: '0x01',
522+
})
523+
.addTx({ tx_id: '0x1111' })
524+
.build();
525+
await db.update(block2a);
526+
const block3a = new TestBlockBuilder({
527+
block_height: 3,
528+
index_block_hash: '0x03ff',
529+
parent_index_block_hash: '0x02ff',
530+
})
531+
.addTx({ tx_id: '0x1112' })
532+
.build();
533+
await db.update(block3a);
534+
const block4 = new TestBlockBuilder({
535+
block_height: 4,
536+
index_block_hash: '0x04',
537+
parent_index_block_hash: '0x03ff',
538+
})
539+
.addTx({ tx_id: '0x1113' })
540+
.build();
541+
await db.update(block4);
542+
543+
// Cache no longer works for tx #1.
544+
const request9 = await supertest(api.server)
545+
.get(`/extended/v1/tx/${txId1}`)
546+
.set('If-None-Match', etag2);
547+
expect(request9.status).toBe(200);
548+
expect(request9.headers['etag']).toBeTruthy();
549+
const etag4 = request9.headers['etag'];
550+
551+
// Cache works again with new ETag.
552+
const request10 = await supertest(api.server)
553+
.get(`/extended/v1/tx/${txId1}`)
554+
.set('If-None-Match', etag4);
555+
expect(request10.status).toBe(304);
556+
expect(request10.text).toBe('');
557+
558+
// Mine tx in a new block
559+
const block5 = new TestBlockBuilder({
560+
block_height: 5,
561+
index_block_hash: '0x05',
562+
parent_index_block_hash: '0x04',
563+
})
564+
.addTx({ tx_id: txId1 })
565+
.build();
566+
await db.update(block5);
567+
568+
// Make sure old cache for confirmed tx doesn't work, because the index_block_hash has changed.
569+
const request11 = await supertest(api.server)
570+
.get(`/extended/v1/tx/${txId1}`)
571+
.set('If-None-Match', etag2);
572+
expect(request11.status).toBe(200);
573+
expect(request11.headers['etag']).toBeTruthy();
574+
const etag5 = request11.headers['etag'];
575+
expect(etag2).not.toBe(etag5);
576+
});
577+
428578
afterEach(async () => {
429579
await api.terminate();
430580
client.release();

0 commit comments

Comments
 (0)