Skip to content

Commit 2370c21

Browse files
authored
feat: add cache handler for principal activity including mempool transactions (#2100)
* feat: mempool principal cache * fix: unify principal activity query * fix: narrow mempool search * test: explicit receipt times
1 parent 28e9864 commit 2370c21

File tree

5 files changed

+167
-70
lines changed

5 files changed

+167
-70
lines changed

src/api/controllers/cache-controller.ts

Lines changed: 22 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ enum ETagType {
2323
transaction = 'transaction',
2424
/** Etag based on the confirmed balance of a single principal (STX address or contract id) */
2525
principal = 'principal',
26+
/** Etag based on `principal` but also including its mempool transactions */
27+
principalMempool = 'principal_mempool',
2628
}
2729

2830
/** Value that means the ETag did get calculated but it is empty. */
@@ -78,23 +80,18 @@ async function calculateETag(
7880
etagType: ETagType,
7981
req: FastifyRequest
8082
): Promise<ETag | undefined> {
81-
switch (etagType) {
82-
case ETagType.chainTip:
83-
try {
83+
try {
84+
switch (etagType) {
85+
case ETagType.chainTip:
8486
const chainTip = await db.getChainTip(db.sql);
8587
if (chainTip.block_height === 0) {
8688
// This should never happen unless the API is serving requests before it has synced any
8789
// blocks.
8890
return;
8991
}
9092
return chainTip.microblock_hash ?? chainTip.index_block_hash;
91-
} catch (error) {
92-
logger.error(error, 'Unable to calculate chain_tip ETag');
93-
return;
94-
}
9593

96-
case ETagType.mempool:
97-
try {
94+
case ETagType.mempool:
9895
const digest = await db.getMempoolTxDigest();
9996
if (!digest.found) {
10097
// This should never happen unless the API is serving requests before it has synced any
@@ -106,13 +103,8 @@ async function calculateETag(
106103
return ETAG_EMPTY;
107104
}
108105
return digest.result.digest;
109-
} catch (error) {
110-
logger.error(error, 'Unable to calculate mempool etag');
111-
return;
112-
}
113106

114-
case ETagType.transaction:
115-
try {
107+
case ETagType.transaction:
116108
const tx_id = (req.params as { tx_id: string }).tx_id;
117109
const normalizedTxId = normalizeHashString(tx_id);
118110
if (normalizedTxId === false) {
@@ -129,23 +121,21 @@ async function calculateETag(
129121
status.result.status.toString(),
130122
];
131123
return sha256(elements.join(':'));
132-
} catch (error) {
133-
logger.error(error, 'Unable to calculate transaction etag');
134-
return;
135-
}
136124

137-
case ETagType.principal:
138-
try {
125+
case ETagType.principal:
126+
case ETagType.principalMempool:
139127
const params = req.params as { address?: string; principal?: string };
140128
const principal = params.address ?? params.principal;
141129
if (!principal) return ETAG_EMPTY;
142-
const activity = await db.getPrincipalLastActivityTxIds(principal);
143-
const text = `${activity.stx_tx_id}:${activity.ft_tx_id}:${activity.nft_tx_id}`;
144-
return sha256(text);
145-
} catch (error) {
146-
logger.error(error, 'Unable to calculate principal etag');
147-
return;
148-
}
130+
const activity = await db.getPrincipalLastActivityTxIds(
131+
principal,
132+
etagType == ETagType.principalMempool
133+
);
134+
if (!activity.length) return ETAG_EMPTY;
135+
return sha256(activity.join(':'));
136+
}
137+
} catch (error) {
138+
logger.error(error, `Unable to calculate ${etagType} etag`);
149139
}
150140
}
151141

@@ -193,3 +183,7 @@ export async function handleTransactionCache(request: FastifyRequest, reply: Fas
193183
export async function handlePrincipalCache(request: FastifyRequest, reply: FastifyReply) {
194184
return handleCache(ETagType.principal, request, reply);
195185
}
186+
187+
export async function handlePrincipalMempoolCache(request: FastifyRequest, reply: FastifyReply) {
188+
return handleCache(ETagType.principalMempool, request, reply);
189+
}

src/api/routes/address.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@ import {
1616
import { InvalidRequestError, InvalidRequestErrorType, NotFoundError } from '../../errors';
1717
import { decodeClarityValueToRepr } from 'stacks-encoding-native-js';
1818
import {
19-
handleChainTipCache,
20-
handleMempoolCache,
2119
handlePrincipalCache,
20+
handlePrincipalMempoolCache,
2221
handleTransactionCache,
2322
} from '../controllers/cache-controller';
2423
import { PgStore } from '../../datastore/pg-store';
@@ -45,7 +44,6 @@ import {
4544
AddressTransactionWithTransfers,
4645
AddressTransactionWithTransfersSchema,
4746
InboundStxTransfer,
48-
InboundStxTransferSchema,
4947
} from '../schemas/entities/addresses';
5048
import { PaginatedResponse } from '../schemas/util';
5149
import { MempoolTransaction, MempoolTransactionSchema } from '../schemas/entities/transactions';
@@ -151,7 +149,7 @@ export const AddressRoutes: FastifyPluginAsync<
151149
schema: {
152150
operationId: 'get_account_balance',
153151
summary: 'Get account balances',
154-
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.`,
152+
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.`,
155153
tags: ['Accounts'],
156154
params: Type.Object({
157155
principal: PrincipalSchema,
@@ -629,7 +627,7 @@ export const AddressRoutes: FastifyPluginAsync<
629627
fastify.get(
630628
'/:principal/mempool',
631629
{
632-
preHandler: handleMempoolCache,
630+
preHandler: handlePrincipalMempoolCache,
633631
schema: {
634632
operationId: 'get_address_mempool_transactions',
635633
summary: 'Transactions for address',
@@ -676,7 +674,7 @@ export const AddressRoutes: FastifyPluginAsync<
676674
fastify.get(
677675
'/:principal/nonces',
678676
{
679-
preHandler: handleMempoolCache,
677+
preHandler: handlePrincipalMempoolCache,
680678
schema: {
681679
operationId: 'get_account_nonces',
682680
summary: 'Get the latest nonce used by an account',

src/datastore/pg-store.ts

Lines changed: 49 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4407,41 +4407,56 @@ export class PgStore extends BasePgStore {
44074407

44084408
/** Retrieves the last transaction IDs with STX, FT and NFT activity for a principal */
44094409
async getPrincipalLastActivityTxIds(
4410-
principal: string
4411-
): Promise<{ stx_tx_id: string | null; ft_tx_id: string | null; nft_tx_id: string | null }> {
4412-
const result = await this.sql<
4413-
{ stx_tx_id: string | null; ft_tx_id: string | null; nft_tx_id: string | null }[]
4414-
>`
4415-
WITH last_stx AS (
4416-
SELECT tx_id
4417-
FROM principal_stx_txs
4418-
WHERE principal = ${principal} AND canonical = true AND microblock_canonical = true
4419-
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC
4420-
LIMIT 1
4421-
),
4422-
last_ft AS (
4423-
SELECT tx_id
4424-
FROM ft_events
4425-
WHERE (sender = ${principal} OR recipient = ${principal})
4426-
AND canonical = true
4427-
AND microblock_canonical = true
4428-
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC
4429-
LIMIT 1
4430-
),
4431-
last_nft AS (
4432-
SELECT tx_id
4433-
FROM nft_events
4434-
WHERE (sender = ${principal} OR recipient = ${principal})
4435-
AND canonical = true
4436-
AND microblock_canonical = true
4437-
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC
4438-
LIMIT 1
4410+
principal: string,
4411+
includeMempool: boolean = false
4412+
): Promise<string[]> {
4413+
const result = await this.sql<{ tx_id: string }[]>`
4414+
WITH activity AS (
4415+
(
4416+
SELECT tx_id
4417+
FROM principal_stx_txs
4418+
WHERE principal = ${principal} AND canonical = true AND microblock_canonical = true
4419+
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC
4420+
LIMIT 1
4421+
)
4422+
UNION
4423+
(
4424+
SELECT tx_id
4425+
FROM ft_events
4426+
WHERE (sender = ${principal} OR recipient = ${principal})
4427+
AND canonical = true
4428+
AND microblock_canonical = true
4429+
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC
4430+
LIMIT 1
4431+
)
4432+
UNION
4433+
(
4434+
SELECT tx_id
4435+
FROM nft_events
4436+
WHERE (sender = ${principal} OR recipient = ${principal})
4437+
AND canonical = true
4438+
AND microblock_canonical = true
4439+
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC
4440+
LIMIT 1
4441+
)
4442+
${
4443+
includeMempool
4444+
? this.sql`UNION
4445+
(
4446+
SELECT tx_id
4447+
FROM mempool_txs
4448+
WHERE pruned = false AND
4449+
(sender_address = ${principal}
4450+
OR sponsor_address = ${principal}
4451+
OR token_transfer_recipient_address = ${principal})
4452+
ORDER BY receipt_time DESC, sender_address DESC, nonce DESC
4453+
LIMIT 1
4454+
)`
4455+
: this.sql``
4456+
}
44394457
)
4440-
SELECT
4441-
(SELECT tx_id FROM last_stx) AS stx_tx_id,
4442-
(SELECT tx_id FROM last_ft) AS ft_tx_id,
4443-
(SELECT tx_id FROM last_nft) AS nft_tx_id
4458+
SELECT DISTINCT tx_id FROM activity WHERE tx_id IS NOT NULL
44444459
`;
4445-
return result[0];
4460+
return result.map(r => r.tx_id);
44464461
}
44474462
}

tests/api/cache-control.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,4 +730,92 @@ describe('cache-control tests', () => {
730730
expect(request10.status).toBe(304);
731731
expect(request10.text).toBe('');
732732
});
733+
734+
test('principal mempool cache control', async () => {
735+
const sender_address = 'SP3FXEKSA6D4BW3TFP2BWTSREV6FY863Y90YY7D8G';
736+
const url = `/extended/v1/address/${sender_address}/mempool`;
737+
await db.update(
738+
new TestBlockBuilder({
739+
block_height: 1,
740+
index_block_hash: '0x01',
741+
parent_index_block_hash: '0x00',
742+
}).build()
743+
);
744+
745+
// ETag zero.
746+
const request1 = await supertest(api.server).get(url);
747+
expect(request1.status).toBe(200);
748+
expect(request1.type).toBe('application/json');
749+
const etag0 = request1.headers['etag'];
750+
751+
// Add STX tx.
752+
await db.updateMempoolTxs({
753+
mempoolTxs: [testMempoolTx({ tx_id: '0x0001', receipt_time: 1000, sender_address })],
754+
});
755+
756+
// Valid ETag.
757+
const request2 = await supertest(api.server).get(url);
758+
expect(request2.status).toBe(200);
759+
expect(request2.type).toBe('application/json');
760+
expect(request2.headers['etag']).toBeTruthy();
761+
const etag1 = request2.headers['etag'];
762+
expect(etag1).not.toEqual(etag0);
763+
764+
// Cache works with valid ETag.
765+
const request3 = await supertest(api.server).get(url).set('If-None-Match', etag1);
766+
expect(request3.status).toBe(304);
767+
expect(request3.text).toBe('');
768+
769+
// Add sponsor tx.
770+
await db.updateMempoolTxs({
771+
mempoolTxs: [
772+
testMempoolTx({ tx_id: '0x0002', receipt_time: 2000, sponsor_address: sender_address }),
773+
],
774+
});
775+
776+
// Cache is now a miss.
777+
const request4 = await supertest(api.server).get(url).set('If-None-Match', etag1);
778+
expect(request4.status).toBe(200);
779+
expect(request4.type).toBe('application/json');
780+
expect(request4.headers['etag']).not.toEqual(etag1);
781+
const etag2 = request4.headers['etag'];
782+
783+
// Cache works with new ETag.
784+
const request5 = await supertest(api.server).get(url).set('If-None-Match', etag2);
785+
expect(request5.status).toBe(304);
786+
expect(request5.text).toBe('');
787+
788+
// Add token recipient tx.
789+
await db.updateMempoolTxs({
790+
mempoolTxs: [
791+
testMempoolTx({
792+
tx_id: '0x0003',
793+
receipt_time: 3000,
794+
token_transfer_recipient_address: sender_address,
795+
}),
796+
],
797+
});
798+
799+
// Cache is now a miss.
800+
const request6 = await supertest(api.server).get(url).set('If-None-Match', etag2);
801+
expect(request6.status).toBe(200);
802+
expect(request6.type).toBe('application/json');
803+
expect(request6.headers['etag']).not.toEqual(etag2);
804+
const etag3 = request6.headers['etag'];
805+
806+
// Cache works with new ETag.
807+
const request7 = await supertest(api.server).get(url).set('If-None-Match', etag3);
808+
expect(request7.status).toBe(304);
809+
expect(request7.text).toBe('');
810+
811+
// Change mempool with no changes to this address.
812+
await db.updateMempoolTxs({
813+
mempoolTxs: [testMempoolTx({ tx_id: '0x0004', receipt_time: 4000 })],
814+
});
815+
816+
// Cache still works.
817+
const request8 = await supertest(api.server).get(url).set('If-None-Match', etag3);
818+
expect(request8.status).toBe(304);
819+
expect(request8.text).toBe('');
820+
});
733821
});

tests/utils/test-builders.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ interface TestMempoolTxArgs {
301301
nonce?: number;
302302
fee_rate?: bigint;
303303
raw_tx?: string;
304+
sponsor_address?: string;
305+
receipt_time?: number;
304306
}
305307

306308
/**
@@ -316,12 +318,12 @@ export function testMempoolTx(args?: TestMempoolTxArgs): DbMempoolTxRaw {
316318
nonce: args?.nonce ?? 0,
317319
raw_tx: args?.raw_tx ?? '0x01234567',
318320
type_id: args?.type_id ?? DbTxTypeId.TokenTransfer,
319-
receipt_time: (new Date().getTime() / 1000) | 0,
321+
receipt_time: args?.receipt_time ?? (new Date().getTime() / 1000) | 0,
320322
status: args?.status ?? DbTxStatus.Pending,
321323
post_conditions: '0x01f5',
322324
fee_rate: args?.fee_rate ?? 1234n,
323325
sponsored: false,
324-
sponsor_address: undefined,
326+
sponsor_address: args?.sponsor_address,
325327
origin_hash_mode: 1,
326328
sender_address: args?.sender_address ?? SENDER_ADDRESS,
327329
token_transfer_amount: args?.token_transfer_amount ?? 1234n,

0 commit comments

Comments
 (0)