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 handler for principal activity including mempool transactions #2100

Merged
merged 4 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
50 changes: 22 additions & 28 deletions src/api/controllers/cache-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ enum ETagType {
transaction = 'transaction',
/** Etag based on the confirmed balance of a single principal (STX address or contract id) */
principal = 'principal',
/** Etag based on `principal` but also including its mempool transactions */
principalMempool = 'principal_mempool',
}

/** Value that means the ETag did get calculated but it is empty. */
Expand Down Expand Up @@ -78,23 +80,18 @@ async function calculateETag(
etagType: ETagType,
req: FastifyRequest
): Promise<ETag | undefined> {
switch (etagType) {
case ETagType.chainTip:
try {
try {
switch (etagType) {
case ETagType.chainTip:
const chainTip = await db.getChainTip(db.sql);
if (chainTip.block_height === 0) {
// This should never happen unless the API is serving requests before it has synced any
// blocks.
return;
}
return chainTip.microblock_hash ?? chainTip.index_block_hash;
} catch (error) {
logger.error(error, 'Unable to calculate chain_tip ETag');
return;
}

case ETagType.mempool:
try {
case ETagType.mempool:
const digest = await db.getMempoolTxDigest();
if (!digest.found) {
// This should never happen unless the API is serving requests before it has synced any
Expand All @@ -106,13 +103,8 @@ async function calculateETag(
return ETAG_EMPTY;
}
return digest.result.digest;
} catch (error) {
logger.error(error, 'Unable to calculate mempool etag');
return;
}

case ETagType.transaction:
try {
case ETagType.transaction:
const tx_id = (req.params as { tx_id: string }).tx_id;
const normalizedTxId = normalizeHashString(tx_id);
if (normalizedTxId === false) {
Expand All @@ -129,23 +121,21 @@ async function calculateETag(
status.result.status.toString(),
];
return sha256(elements.join(':'));
} catch (error) {
logger.error(error, 'Unable to calculate transaction etag');
return;
}

case ETagType.principal:
try {
case ETagType.principal:
case ETagType.principalMempool:
const params = req.params as { address?: string; principal?: string };
const principal = params.address ?? params.principal;
if (!principal) return ETAG_EMPTY;
const activity = await db.getPrincipalLastActivityTxIds(principal);
const text = `${activity.stx_tx_id}:${activity.ft_tx_id}:${activity.nft_tx_id}`;
return sha256(text);
} catch (error) {
logger.error(error, 'Unable to calculate principal etag');
return;
}
const activity = await db.getPrincipalLastActivityTxIds(
principal,
etagType == ETagType.principalMempool
);
if (!activity.length) return ETAG_EMPTY;
return sha256(activity.join(':'));
}
} catch (error) {
logger.error(error, `Unable to calculate ${etagType} etag`);
rafaelcr marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down Expand Up @@ -193,3 +183,7 @@ export async function handleTransactionCache(request: FastifyRequest, reply: Fas
export async function handlePrincipalCache(request: FastifyRequest, reply: FastifyReply) {
return handleCache(ETagType.principal, request, reply);
}

export async function handlePrincipalMempoolCache(request: FastifyRequest, reply: FastifyReply) {
return handleCache(ETagType.principalMempool, request, reply);
}
10 changes: 4 additions & 6 deletions src/api/routes/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ import {
import { InvalidRequestError, InvalidRequestErrorType, NotFoundError } from '../../errors';
import { decodeClarityValueToRepr } from 'stacks-encoding-native-js';
import {
handleChainTipCache,
handleMempoolCache,
handlePrincipalCache,
handlePrincipalMempoolCache,
handleTransactionCache,
} from '../controllers/cache-controller';
import { PgStore } from '../../datastore/pg-store';
Expand All @@ -45,7 +44,6 @@ import {
AddressTransactionWithTransfers,
AddressTransactionWithTransfersSchema,
InboundStxTransfer,
InboundStxTransferSchema,
} from '../schemas/entities/addresses';
import { PaginatedResponse } from '../schemas/util';
import { MempoolTransaction, MempoolTransactionSchema } from '../schemas/entities/transactions';
Expand Down Expand Up @@ -151,7 +149,7 @@ export const AddressRoutes: FastifyPluginAsync<
schema: {
operationId: 'get_account_balance',
summary: 'Get account balances',
description: `Retrieves total account balance information for a given Address or Contract Identifier. This includes the balances of STX Tokens, Fungible Tokens and Non-Fungible Tokens for the account.`,
description: `Retrieves total account balance information for a given Address or Contract Identifier. This includes the balances of STX Tokens, Fungible Tokens and Non-Fungible Tokens for the account.`,
tags: ['Accounts'],
params: Type.Object({
principal: PrincipalSchema,
Expand Down Expand Up @@ -629,7 +627,7 @@ export const AddressRoutes: FastifyPluginAsync<
fastify.get(
'/:principal/mempool',
{
preHandler: handleMempoolCache,
preHandler: handlePrincipalMempoolCache,
schema: {
operationId: 'get_address_mempool_transactions',
summary: 'Transactions for address',
Expand Down Expand Up @@ -676,7 +674,7 @@ export const AddressRoutes: FastifyPluginAsync<
fastify.get(
'/:principal/nonces',
{
preHandler: handleMempoolCache,
preHandler: handlePrincipalMempoolCache,
schema: {
operationId: 'get_account_nonces',
summary: 'Get the latest nonce used by an account',
Expand Down
83 changes: 49 additions & 34 deletions src/datastore/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4407,41 +4407,56 @@ export class PgStore extends BasePgStore {

/** Retrieves the last transaction IDs with STX, FT and NFT activity for a principal */
async getPrincipalLastActivityTxIds(
principal: string
): Promise<{ stx_tx_id: string | null; ft_tx_id: string | null; nft_tx_id: string | null }> {
const result = await this.sql<
{ stx_tx_id: string | null; ft_tx_id: string | null; nft_tx_id: string | null }[]
>`
WITH last_stx AS (
SELECT tx_id
FROM principal_stx_txs
WHERE principal = ${principal} AND canonical = true AND microblock_canonical = true
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC
LIMIT 1
),
last_ft AS (
SELECT tx_id
FROM ft_events
WHERE (sender = ${principal} OR recipient = ${principal})
AND canonical = true
AND microblock_canonical = true
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC
LIMIT 1
),
last_nft AS (
SELECT tx_id
FROM nft_events
WHERE (sender = ${principal} OR recipient = ${principal})
AND canonical = true
AND microblock_canonical = true
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC
LIMIT 1
principal: string,
includeMempool: boolean = false
): Promise<string[]> {
const result = await this.sql<{ tx_id: string }[]>`
WITH activity AS (
(
SELECT tx_id
FROM principal_stx_txs
WHERE principal = ${principal} AND canonical = true AND microblock_canonical = true
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC
LIMIT 1
)
UNION
(
SELECT tx_id
FROM ft_events
WHERE (sender = ${principal} OR recipient = ${principal})
AND canonical = true
AND microblock_canonical = true
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC
LIMIT 1
)
UNION
(
SELECT tx_id
FROM nft_events
WHERE (sender = ${principal} OR recipient = ${principal})
AND canonical = true
AND microblock_canonical = true
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC
LIMIT 1
)
${
includeMempool
? this.sql`UNION
(
SELECT tx_id
FROM mempool_txs
WHERE pruned = false AND
(sender_address = ${principal}
OR sponsor_address = ${principal}
OR token_transfer_recipient_address = ${principal})
ORDER BY receipt_time DESC, sender_address DESC, nonce DESC
rafaelcr marked this conversation as resolved.
Show resolved Hide resolved
LIMIT 1
)`
: this.sql``
}
)
SELECT
(SELECT tx_id FROM last_stx) AS stx_tx_id,
(SELECT tx_id FROM last_ft) AS ft_tx_id,
(SELECT tx_id FROM last_nft) AS nft_tx_id
SELECT DISTINCT tx_id FROM activity WHERE tx_id IS NOT NULL
`;
return result[0];
return result.map(r => r.tx_id);
}
}
78 changes: 78 additions & 0 deletions tests/api/cache-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,4 +730,82 @@ describe('cache-control tests', () => {
expect(request10.status).toBe(304);
expect(request10.text).toBe('');
});

test('principal mempool cache control', async () => {
const sender_address = 'SP3FXEKSA6D4BW3TFP2BWTSREV6FY863Y90YY7D8G';
const url = `/extended/v1/address/${sender_address}/mempool`;
await db.update(
new TestBlockBuilder({
block_height: 1,
index_block_hash: '0x01',
parent_index_block_hash: '0x00',
}).build()
);

// ETag zero.
const request1 = await supertest(api.server).get(url);
expect(request1.status).toBe(200);
expect(request1.type).toBe('application/json');
const etag0 = request1.headers['etag'];

// Add STX tx.
await db.updateMempoolTxs({ mempoolTxs: [testMempoolTx({ tx_id: '0x0001', sender_address })] });

// Valid ETag.
const request2 = await supertest(api.server).get(url);
expect(request2.status).toBe(200);
expect(request2.type).toBe('application/json');
expect(request2.headers['etag']).toBeTruthy();
const etag1 = request2.headers['etag'];
expect(etag1).not.toEqual(etag0);

// Cache works with valid ETag.
const request3 = await supertest(api.server).get(url).set('If-None-Match', etag1);
expect(request3.status).toBe(304);
expect(request3.text).toBe('');

// Add sponsor tx.
await db.updateMempoolTxs({
mempoolTxs: [testMempoolTx({ tx_id: '0x0002', sponsor_address: sender_address })],
});

// Cache is now a miss.
const request4 = await supertest(api.server).get(url).set('If-None-Match', etag1);
expect(request4.status).toBe(200);
expect(request4.type).toBe('application/json');
expect(request4.headers['etag']).not.toEqual(etag1);
const etag2 = request4.headers['etag'];

// Cache works with new ETag.
const request5 = await supertest(api.server).get(url).set('If-None-Match', etag2);
expect(request5.status).toBe(304);
expect(request5.text).toBe('');

// Add token recipient tx.
await db.updateMempoolTxs({
mempoolTxs: [
testMempoolTx({ tx_id: '0x0003', token_transfer_recipient_address: sender_address }),
],
});

// Cache is now a miss.
const request6 = await supertest(api.server).get(url).set('If-None-Match', etag2);
expect(request6.status).toBe(200);
expect(request6.type).toBe('application/json');
expect(request6.headers['etag']).not.toEqual(etag2);
const etag3 = request6.headers['etag'];

// Cache works with new ETag.
const request7 = await supertest(api.server).get(url).set('If-None-Match', etag3);
expect(request7.status).toBe(304);
expect(request7.text).toBe('');

// Change mempool with no changes to this address.
await db.updateMempoolTxs({ mempoolTxs: [testMempoolTx({ tx_id: '0x0004' })] });

// Cache still works.
const request8 = await supertest(api.server).get(url).set('If-None-Match', etag3);
expect(request8.status).toBe(304);
expect(request8.text).toBe('');
});
});
3 changes: 2 additions & 1 deletion tests/utils/test-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ interface TestMempoolTxArgs {
nonce?: number;
fee_rate?: bigint;
raw_tx?: string;
sponsor_address?: string;
}

/**
Expand All @@ -321,7 +322,7 @@ export function testMempoolTx(args?: TestMempoolTxArgs): DbMempoolTxRaw {
post_conditions: '0x01f5',
fee_rate: args?.fee_rate ?? 1234n,
sponsored: false,
sponsor_address: undefined,
sponsor_address: args?.sponsor_address,
origin_hash_mode: 1,
sender_address: args?.sender_address ?? SENDER_ADDRESS,
token_transfer_amount: args?.token_transfer_amount ?? 1234n,
Expand Down
Loading