Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add cache control to /extended/v1/tx/:tx_id #1229

Merged
merged 5 commits into from
Jul 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions src/api/controllers/cache-controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { RequestHandler, Request, Response } from 'express';
import * as prom from 'prom-client';
import { logger } from '../../helpers';
import { bufferToHexPrefixString, logger, normalizeHashString } from '../../helpers';
import { DataStore } from '../../datastore/common';
import { asyncHandler } from '../async-handler';

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

/** Value that means the ETag did get calculated but it is empty. */
Expand Down Expand Up @@ -166,7 +168,7 @@ async function checkETagCacheOK(
etagType: ETagType
): Promise<ETag | undefined | typeof CACHE_OK> {
const metrics = getETagMetrics();
const etag = await calculateETag(db, etagType);
const etag = await calculateETag(db, etagType, req);
if (!etag || etag === ETAG_EMPTY) {
return;
}
Expand Down Expand Up @@ -240,7 +242,11 @@ export function getETagCacheHandler(
return requestHandler;
}

async function calculateETag(db: DataStore, etagType: ETagType): Promise<ETag | undefined> {
async function calculateETag(
db: DataStore,
etagType: ETagType,
req: Request
): Promise<ETag | undefined> {
switch (etagType) {
case ETagType.chainTip:
const chainTip = await db.getUnanchoredChainTip();
Expand All @@ -261,5 +267,23 @@ async function calculateETag(db: DataStore, etagType: ETagType): Promise<ETag |
return ETAG_EMPTY;
}
return digest.result.digest;

case ETagType.transaction:
const { tx_id } = req.params;
const normalizedTxId = normalizeHashString(tx_id);
if (normalizedTxId === false) {
return ETAG_EMPTY;
}
const status = await db.getTxStatus(normalizedTxId);
if (!status.found) {
return ETAG_EMPTY;
}
const elements: string[] = [
normalizedTxId,
bufferToHexPrefixString(status.result.index_block_hash),
bufferToHexPrefixString(status.result.microblock_hash),
status.result.status.toString(),
];
return elements.join(':');
}
}
1 change: 1 addition & 0 deletions src/api/controllers/db-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export function getTxStatusString(
case DbTxStatus.DroppedTooExpensive:
return 'dropped_too_expensive';
case DbTxStatus.DroppedStaleGarbageCollect:
case DbTxStatus.DroppedApiGarbageCollect:
return 'dropped_stale_garbage_collect';
default:
throw new Error(`Unexpected DbTxStatus: ${txStatus}`);
Expand Down
14 changes: 5 additions & 9 deletions src/api/routes/tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export function createTxRouter(db: DataStore): express.Router {

const cacheHandler = getETagCacheHandler(db);
const mempoolCacheHandler = getETagCacheHandler(db, ETagType.mempool);
const txCacheHandler = getETagCacheHandler(db, ETagType.transaction);

router.get(
'/',
Expand Down Expand Up @@ -285,9 +286,9 @@ export function createTxRouter(db: DataStore): express.Router {
})
);

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

// TODO: Add cache headers. Impossible right now since this tx might be from a block or from the mempool.
router.get(
'/:tx_id/raw',
txCacheHandler,
asyncHandler(async (req, res) => {
const { tx_id } = req.params;
if (!has0xPrefix(tx_id)) {
Expand All @@ -336,6 +331,7 @@ export function createTxRouter(db: DataStore): express.Router {
const response: GetRawTransactionResult = {
raw_tx: bufferToHexPrefixString(rawTxQuery.result.raw_tx),
};
setETagCacheHeaders(res, ETagType.transaction);
res.json(response);
} else {
res.status(404).json({ error: `could not find transaction by ID ${tx_id}` });
Expand Down
14 changes: 9 additions & 5 deletions src/datastore/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,7 @@ import {
TxPayloadTypeID,
PostConditionAuthFlag,
} from 'stacks-encoding-native-js';
import {
AddressTokenOfferingLocked,
MempoolTransaction,
TransactionType,
} from '@stacks/stacks-blockchain-api-types';
import { AddressTokenOfferingLocked, TransactionType } from '@stacks/stacks-blockchain-api-types';
import { getTxSenderAddress } from '../event-stream/reader';
import { RawTxQueryResult } from './postgres-store';
import { ChainID, ClarityAbi } from '@stacks/transactions';
Expand Down Expand Up @@ -615,6 +611,12 @@ export interface DbChainTip {
microblockSequence?: number;
}

export interface DbTxGlobalStatus {
status: DbTxStatus;
index_block_hash: Buffer;
microblock_hash: Buffer;
}

export interface DataStore extends DataStoreEventEmitter {
storeRawEventRequest(eventPath: string, payload: string): Promise<void>;
getSubdomainResolver(name: { name: string }): Promise<FoundOrNot<string>>;
Expand Down Expand Up @@ -865,6 +867,8 @@ export interface DataStore extends DataStoreEventEmitter {

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

getTxStatus(txId: string): Promise<FoundOrNot<DbTxGlobalStatus>>;

/**
* Returns a list of NFTs owned by the given principal filtered by optional `asset_identifiers`,
* including optional transaction metadata.
Expand Down
43 changes: 43 additions & 0 deletions src/datastore/postgres-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,15 @@ import {
NftHoldingInfoWithTxMetadata,
NftEventWithTxMetadata,
DbAssetEventTypeId,
DbTxGlobalStatus,
} from './common';
import {
AddressTokenOfferingLocked,
TransactionType,
AddressUnlockSchedule,
Block,
MempoolTransactionStatus,
TransactionStatus,
} from '@stacks/stacks-blockchain-api-types';
import { getTxTypeId } from '../api/controllers/db-controller';
import { isProcessableTokenMetadata } from '../token-metadata/helpers';
Expand Down Expand Up @@ -4053,6 +4056,46 @@ export class PgDataStore
});
}

async getTxStatus(txId: string): Promise<FoundOrNot<DbTxGlobalStatus>> {
return this.queryTx(async client => {
const chainResult = await client.query<DbTxGlobalStatus>(
`SELECT status, index_block_hash, microblock_hash
FROM txs
WHERE tx_id = $1 AND canonical = TRUE AND microblock_canonical = TRUE
LIMIT 1`,
[hexToBuffer(txId)]
);
if (chainResult.rowCount > 0) {
return {
found: true,
result: {
status: chainResult.rows[0].status,
index_block_hash: chainResult.rows[0].index_block_hash,
microblock_hash: chainResult.rows[0].microblock_hash,
},
};
}
const mempoolResult = await client.query<{ status: number }>(
`SELECT status
FROM mempool_txs
WHERE tx_id = $1
LIMIT 1`,
[hexToBuffer(txId)]
);
if (mempoolResult.rowCount > 0) {
return {
found: true,
result: {
status: mempoolResult.rows[0].status,
index_block_hash: Buffer.from([]),
microblock_hash: Buffer.from([]),
},
};
}
return { found: false } as const;
});
}

async getMaxBlockHeight(
client: ClientBase,
{ includeUnanchored }: { includeUnanchored: boolean }
Expand Down
150 changes: 150 additions & 0 deletions src/tests/cache-control-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,156 @@ describe('cache-control tests', () => {
expect(request8.headers['etag']).toBeUndefined();
});

test('transaction cache control', async () => {
const txId1 = '0x0153a41ed24a0e1d32f66ea98338df09f942571ca66359e28bdca79ccd0305cf';
const txId2 = '0xfb4bfc274007825dfd2d8f6c3f429407016779e9954775f82129108282d4c4ce';

const block1 = new TestBlockBuilder({
block_height: 1,
index_block_hash: '0x01',
})
.addTx()
.build();
await db.update(block1);

// No tx yet.
const request1 = await supertest(api.server).get(`/extended/v1/tx/${txId1}`);
expect(request1.status).toBe(404);
expect(request1.type).toBe('application/json');

// Add mempool tx.
const mempoolTx1 = testMempoolTx({ tx_id: txId1 });
await db.updateMempoolTxs({ mempoolTxs: [mempoolTx1] });

// Valid mempool ETag.
const request2 = await supertest(api.server).get(`/extended/v1/tx/${txId1}`);
expect(request2.status).toBe(200);
expect(request2.type).toBe('application/json');
expect(request2.headers['etag']).toBeTruthy();
const etag1 = request2.headers['etag'];

// Cache works with valid ETag.
const request3 = await supertest(api.server)
.get(`/extended/v1/tx/${txId1}`)
.set('If-None-Match', etag1);
expect(request3.status).toBe(304);
expect(request3.text).toBe('');

// Mine the same tx into a block
const block2 = new TestBlockBuilder({
block_height: 2,
index_block_hash: '0x02',
parent_index_block_hash: '0x01',
})
.addTx({ tx_id: txId1 })
.build();
await db.update(block2);

// Cache no longer works with mempool ETag but we get updated ETag.
const request4 = await supertest(api.server)
.get(`/extended/v1/tx/${txId1}`)
.set('If-None-Match', etag1);
expect(request4.status).toBe(200);
expect(request4.headers['etag']).toBeTruthy();
const etag2 = request4.headers['etag'];

// Cache works with new ETag.
const request5 = await supertest(api.server)
.get(`/extended/v1/tx/${txId1}`)
.set('If-None-Match', etag2);
expect(request5.status).toBe(304);
expect(request5.text).toBe('');

// No tx #2 yet.
const request6 = await supertest(api.server).get(`/extended/v1/tx/${txId2}`);
expect(request6.status).toBe(404);
expect(request6.type).toBe('application/json');

// Tx #2 directly into a block
const block3 = new TestBlockBuilder({
block_height: 3,
index_block_hash: '0x03',
parent_index_block_hash: '0x02',
})
.addTx({ tx_id: txId2 })
.build();
await db.update(block3);

// Valid block ETag.
const request7 = await supertest(api.server).get(`/extended/v1/tx/${txId2}`);
expect(request7.status).toBe(200);
expect(request7.type).toBe('application/json');
expect(request7.headers['etag']).toBeTruthy();
const etag3 = request7.headers['etag'];

// Cache works with valid ETag.
const request8 = await supertest(api.server)
.get(`/extended/v1/tx/${txId2}`)
.set('If-None-Match', etag3);
expect(request8.status).toBe(304);
expect(request8.text).toBe('');

// Oops, new blocks came, all txs before are non-canonical
const block2a = new TestBlockBuilder({
block_height: 2,
index_block_hash: '0x02ff',
parent_index_block_hash: '0x01',
})
.addTx({ tx_id: '0x1111' })
.build();
await db.update(block2a);
const block3a = new TestBlockBuilder({
block_height: 3,
index_block_hash: '0x03ff',
parent_index_block_hash: '0x02ff',
})
.addTx({ tx_id: '0x1112' })
.build();
await db.update(block3a);
const block4 = new TestBlockBuilder({
block_height: 4,
index_block_hash: '0x04',
parent_index_block_hash: '0x03ff',
})
.addTx({ tx_id: '0x1113' })
.build();
await db.update(block4);

// Cache no longer works for tx #1.
const request9 = await supertest(api.server)
.get(`/extended/v1/tx/${txId1}`)
.set('If-None-Match', etag2);
expect(request9.status).toBe(200);
expect(request9.headers['etag']).toBeTruthy();
const etag4 = request9.headers['etag'];

// Cache works again with new ETag.
const request10 = await supertest(api.server)
.get(`/extended/v1/tx/${txId1}`)
.set('If-None-Match', etag4);
expect(request10.status).toBe(304);
expect(request10.text).toBe('');

// Mine tx in a new block
const block5 = new TestBlockBuilder({
block_height: 5,
index_block_hash: '0x05',
parent_index_block_hash: '0x04',
})
.addTx({ tx_id: txId1 })
.build();
await db.update(block5);

// Make sure old cache for confirmed tx doesn't work, because the index_block_hash has changed.
const request11 = await supertest(api.server)
.get(`/extended/v1/tx/${txId1}`)
.set('If-None-Match', etag2);
expect(request11.status).toBe(200);
expect(request11.headers['etag']).toBeTruthy();
const etag5 = request11.headers['etag'];
expect(etag2).not.toBe(etag5);
});

afterEach(async () => {
await api.terminate();
client.release();
Expand Down