Skip to content

Commit 6fe829c

Browse files
authored
feat!: add mempool endpoint cache handlers (#1115)
* feat: add mempool digest materialized view * fix: only use bit_xor if we're on pg14+ * chore: update pg version to 14 on docker compose * feat: install mempool cache handler, add chain tip handler to other endpoints * fix: comment tweaks * fix: only error on missing chain_tip etags * chore: add tests * fix: check if bit_xor is available instead of pg version * chore: add note about pg14 to readme * feat: add mempool specific metrics * style: readme spacing * fix: set an explicit empty value for etags * fix: empty value vs null value
1 parent 667d137 commit 6fe829c

14 files changed

+356
-84
lines changed

docker/docker-compose.dev.postgres.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
version: '3.7'
22
services:
33
postgres:
4-
image: "postgres:12.2"
4+
image: "postgres:14"
55
ports:
66
- "5490:5432"
77
environment:

docker/docker-compose.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
version: '3.7'
22
services:
33
postgres:
4-
image: "postgres:12.2"
4+
image: "postgres:14"
55
ports:
66
- "5490:5432"
77
environment:

readme.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,11 @@ See [overview.md](overview.md) for architecture details.
5454

5555
# Deployment
5656

57+
For optimal performance, we recommend running the API database on PostgreSQL version 14 or newer.
58+
5759
## Upgrading
5860

59-
If upgrading the API to a new major version (e.g. `3.0.0` to `4.0.0`) then the Postgres database from the previous version will not be compatible and the process will fail to start.
61+
If upgrading the API to a new major version (e.g. `3.0.0` to `4.0.0`) then the Postgres database from the previous version will not be compatible and the process will fail to start.
6062

6163
[Event Replay](#event-replay) must be used when upgrading major versions. Follow the event replay [instructions](#event-replay-instructions) below. Failure to do so will require wiping both the Stacks Blockchain chainstate data and the API Postgres database, and re-syncing from scratch.
6264

@@ -116,7 +118,7 @@ event's to be re-played, the following steps could be ran:
116118
```
117119
1. Update to the new stacks-blockchain-api version.
118120
1. Perform the event playback using the `import-events` command:
119-
121+
120122
**WARNING**: This will **drop _all_ tables** from the configured Postgres database, including any tables not automatically added by the API.
121123

122124
```shell
+124-55
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,50 @@
11
import { RequestHandler, Request, Response } from 'express';
22
import * as prom from 'prom-client';
3-
import { FoundOrNot, logger } from '../../helpers';
4-
import { DataStore, DbChainTip } from '../../datastore/common';
3+
import { logger } from '../../helpers';
4+
import { DataStore } from '../../datastore/common';
55
import { asyncHandler } from '../async-handler';
66

77
const CACHE_OK = Symbol('cache_ok');
8-
const CHAIN_TIP_LOCAL = 'chain_tip';
98

10-
// A `Cache-Control` header used for re-validation based caching.
11-
// `public` == allow proxies/CDNs to cache as opposed to only local browsers.
12-
// `no-cache` == clients can cache a resource but should revalidate each time before using it.
13-
// `must-revalidate` == somewhat redundant directive to assert that cache must be revalidated, required by some CDNs
9+
/**
10+
* A `Cache-Control` header used for re-validation based caching.
11+
* * `public` == allow proxies/CDNs to cache as opposed to only local browsers.
12+
* * `no-cache` == clients can cache a resource but should revalidate each time before using it.
13+
* * `must-revalidate` == somewhat redundant directive to assert that cache must be revalidated, required by some CDNs
14+
*/
1415
const CACHE_CONTROL_MUST_REVALIDATE = 'public, no-cache, must-revalidate';
1516

16-
interface ChainTipCacheMetrics {
17+
/**
18+
* Describes a key-value to be saved into a request's locals, representing the current
19+
* state of the chain depending on the type of information being requested by the endpoint.
20+
* This entry will have an `ETag` string as the value.
21+
*/
22+
export enum ETagType {
23+
/** ETag based on the latest `index_block_hash` or `microblock_hash`. */
24+
chainTip = 'chain_tip',
25+
/** ETag based on a digest of all pending mempool `tx_id`s. */
26+
mempool = 'mempool',
27+
}
28+
29+
/** Value that means the ETag did get calculated but it is empty. */
30+
const ETAG_EMPTY = Symbol(-1);
31+
type ETag = string | typeof ETAG_EMPTY;
32+
33+
interface ETagCacheMetrics {
1734
chainTipCacheHits: prom.Counter<string>;
1835
chainTipCacheMisses: prom.Counter<string>;
1936
chainTipCacheNoHeader: prom.Counter<string>;
37+
mempoolCacheHits: prom.Counter<string>;
38+
mempoolCacheMisses: prom.Counter<string>;
39+
mempoolCacheNoHeader: prom.Counter<string>;
2040
}
2141

22-
let _chainTipMetrics: ChainTipCacheMetrics | undefined;
23-
function getChainTipMetrics(): ChainTipCacheMetrics {
24-
if (_chainTipMetrics !== undefined) {
25-
return _chainTipMetrics;
42+
let _eTagMetrics: ETagCacheMetrics | undefined;
43+
function getETagMetrics(): ETagCacheMetrics {
44+
if (_eTagMetrics !== undefined) {
45+
return _eTagMetrics;
2646
}
27-
const metrics: ChainTipCacheMetrics = {
47+
const metrics: ETagCacheMetrics = {
2848
chainTipCacheHits: new prom.Counter({
2949
name: 'chain_tip_cache_hits',
3050
help: 'Total count of requests with an up-to-date chain tip cache header',
@@ -37,9 +57,21 @@ function getChainTipMetrics(): ChainTipCacheMetrics {
3757
name: 'chain_tip_cache_no_header',
3858
help: 'Total count of requests that did not provide a chain tip header',
3959
}),
60+
mempoolCacheHits: new prom.Counter({
61+
name: 'mempool_cache_hits',
62+
help: 'Total count of requests with an up-to-date mempool cache header',
63+
}),
64+
mempoolCacheMisses: new prom.Counter({
65+
name: 'mempool_cache_misses',
66+
help: 'Total count of requests with a stale mempool cache header',
67+
}),
68+
mempoolCacheNoHeader: new prom.Counter({
69+
name: 'mempool_cache_no_header',
70+
help: 'Total count of requests that did not provide a mempool header',
71+
}),
4072
};
41-
_chainTipMetrics = metrics;
42-
return _chainTipMetrics;
73+
_eTagMetrics = metrics;
74+
return _eTagMetrics;
4375
}
4476

4577
export function setResponseNonCacheable(res: Response) {
@@ -48,31 +80,29 @@ export function setResponseNonCacheable(res: Response) {
4880
}
4981

5082
/**
51-
* Sets the response `Cache-Control` and `ETag` headers using the chain tip previously added
83+
* Sets the response `Cache-Control` and `ETag` headers using the etag previously added
5284
* to the response locals.
53-
* Uses the latest unanchored microblock hash if available, otherwise uses the anchor
54-
* block index hash.
5585
*/
56-
export function setChainTipCacheHeaders(res: Response) {
57-
const chainTip: FoundOrNot<DbChainTip> | undefined = res.locals[CHAIN_TIP_LOCAL];
58-
if (!chainTip) {
86+
export function setETagCacheHeaders(res: Response, etagType: ETagType = ETagType.chainTip) {
87+
const etag: ETag | undefined = res.locals[etagType];
88+
if (!etag) {
5989
logger.error(
60-
`Cannot set cache control headers, no chain tip was set on \`Response.locals[CHAIN_TIP_LOCAL]\`.`
90+
`Cannot set cache control headers, no etag was set on \`Response.locals[${etagType}]\`.`
6191
);
6292
return;
6393
}
64-
if (!chainTip.found) {
94+
if (etag === ETAG_EMPTY) {
6595
return;
6696
}
67-
const chainTipTag = chainTip.result.microblockHash ?? chainTip.result.indexBlockHash;
6897
res.set({
6998
'Cache-Control': CACHE_CONTROL_MUST_REVALIDATE,
70-
// Use the current chain tip `indexBlockHash` as the etag so that cache is invalidated on new blocks.
99+
// Use the current chain tip or mempool state as the etag so that cache is invalidated on new blocks or
100+
// new mempool events.
71101
// This value will be provided in the `If-None-Match` request header in subsequent requests.
72102
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
73103
// > Entity tag that uniquely represents the requested resource.
74104
// > It is a string of ASCII characters placed between double quotes..
75-
ETag: `"${chainTipTag}"`,
105+
ETag: `"${etag}"`,
76106
});
77107
}
78108

@@ -123,49 +153,61 @@ export function parseIfNoneMatchHeader(
123153
}
124154

125155
/**
126-
* Parse the `ETag` from the given request's `If-None-Match` header which represents the chain tip associated
127-
* with the client's cached response. Query the current chain tip from the db, and compare the two.
156+
* Parse the `ETag` from the given request's `If-None-Match` header which represents the chain tip or
157+
* mempool state associated with the client's cached response. Query the current state from the db, and
158+
* compare the two.
128159
* This function is also responsible for tracking the prometheus metrics associated with cache hits/misses.
129-
* @returns `CACHE_OK` if the client's cached response is up-to-date with the current chain tip, otherwise,
130-
* returns the current chain tip which can be used later for setting the cache control etag response header.
160+
* @returns `CACHE_OK` if the client's cached response is up-to-date with the current state, otherwise,
161+
* returns a string which can be used later for setting the cache control `ETag` response header.
131162
*/
132-
async function checkChainTipCacheOK(
163+
async function checkETagCacheOK(
133164
db: DataStore,
134-
req: Request
135-
): Promise<FoundOrNot<DbChainTip> | typeof CACHE_OK> {
136-
const metrics = getChainTipMetrics();
137-
const chainTip = await db.getUnanchoredChainTip();
138-
if (!chainTip.found) {
139-
// This should never happen unless the API is serving requests before it has synced any blocks.
140-
return chainTip;
165+
req: Request,
166+
etagType: ETagType
167+
): Promise<ETag | undefined | typeof CACHE_OK> {
168+
const metrics = getETagMetrics();
169+
const etag = await calculateETag(db, etagType);
170+
if (!etag || etag === ETAG_EMPTY) {
171+
return;
141172
}
142173
// Parse ETag values from the request's `If-None-Match` header, if any.
143174
// Note: node.js normalizes `IncomingMessage.headers` to lowercase.
144175
const ifNoneMatch = parseIfNoneMatchHeader(req.headers['if-none-match']);
145176
if (ifNoneMatch === undefined || ifNoneMatch.length === 0) {
146177
// No if-none-match header specified.
147-
metrics.chainTipCacheNoHeader.inc();
148-
return chainTip;
178+
if (etagType === ETagType.chainTip) {
179+
metrics.chainTipCacheNoHeader.inc();
180+
} else {
181+
metrics.mempoolCacheNoHeader.inc();
182+
}
183+
return etag;
149184
}
150-
const chainTipTag = chainTip.result.microblockHash ?? chainTip.result.indexBlockHash;
151-
if (ifNoneMatch.includes(chainTipTag)) {
152-
// The client cache's ETag matches the current chain tip, so no need to re-process the request
185+
if (ifNoneMatch.includes(etag)) {
186+
// The client cache's ETag matches the current state, so no need to re-process the request
153187
// server-side as there will be no change in response. Record this as a "cache hit" and return CACHE_OK.
154-
metrics.chainTipCacheHits.inc();
188+
if (etagType === ETagType.chainTip) {
189+
metrics.chainTipCacheHits.inc();
190+
} else {
191+
metrics.mempoolCacheHits.inc();
192+
}
155193
return CACHE_OK;
156194
} else {
157-
// The client cache's ETag is associated with an different block than current latest chain tip, typically
195+
// The client cache's ETag is associated with an different block than current latest state, typically
158196
// an older block or a forked block, so the client's cached response is stale and should not be used.
159-
// Record this as a "cache miss" and return the current chain tip.
160-
metrics.chainTipCacheMisses.inc();
161-
return chainTip;
197+
// Record this as a "cache miss" and return the current state.
198+
if (etagType === ETagType.chainTip) {
199+
metrics.chainTipCacheMisses.inc();
200+
} else {
201+
metrics.mempoolCacheMisses.inc();
202+
}
203+
return etag;
162204
}
163205
}
164206

165207
/**
166208
* Check if the request has an up-to-date cached response by comparing the `If-None-Match` request header to the
167-
* current chain tip. If the cache is valid then a `304 Not Modified` response is sent and the route handling for
168-
* this request is completed. If the cache is outdated, the current chain tip is added to the `Request.locals` for
209+
* current state. If the cache is valid then a `304 Not Modified` response is sent and the route handling for
210+
* this request is completed. If the cache is outdated, the current state is added to the `Request.locals` for
169211
* later use in setting response cache headers.
170212
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#freshness
171213
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
@@ -176,21 +218,48 @@ async function checkChainTipCacheOK(
176218
* doesn't match any of the values listed.
177219
* ```
178220
*/
179-
export function getChainTipCacheHandler(db: DataStore): RequestHandler {
221+
export function getETagCacheHandler(
222+
db: DataStore,
223+
etagType: ETagType = ETagType.chainTip
224+
): RequestHandler {
180225
const requestHandler = asyncHandler(async (req, res, next) => {
181-
const result = await checkChainTipCacheOK(db, req);
226+
const result = await checkETagCacheOK(db, req, etagType);
182227
if (result === CACHE_OK) {
183228
// Instruct the client to use the cached response via a `304 Not Modified` response header.
184229
// This completes the handling for this request, do not call `next()` in order to skip the
185230
// router handler used for non-cached responses.
186231
res.set('Cache-Control', CACHE_CONTROL_MUST_REVALIDATE).status(304).send();
187232
} else {
188-
// Request does not have a valid cache. Store the chainTip for later
233+
// Request does not have a valid cache. Store the etag for later
189234
// use in setting response cache headers.
190-
const chainTip: FoundOrNot<DbChainTip> = result;
191-
res.locals[CHAIN_TIP_LOCAL] = chainTip;
235+
const etag: ETag | undefined = result;
236+
res.locals[etagType] = etag;
192237
next();
193238
}
194239
});
195240
return requestHandler;
196241
}
242+
243+
async function calculateETag(db: DataStore, etagType: ETagType): Promise<ETag | undefined> {
244+
switch (etagType) {
245+
case ETagType.chainTip:
246+
const chainTip = await db.getUnanchoredChainTip();
247+
if (!chainTip.found) {
248+
// This should never happen unless the API is serving requests before it has synced any blocks.
249+
return;
250+
}
251+
return chainTip.result.microblockHash ?? chainTip.result.indexBlockHash;
252+
253+
case ETagType.mempool:
254+
const digest = await db.getMempoolTxDigest();
255+
if (!digest.found) {
256+
// This should never happen unless the API is serving requests before it has synced any blocks.
257+
return;
258+
}
259+
if (digest.result.digest === null) {
260+
// A `null` mempool digest means the `bit_xor` postgres function is unavailable.
261+
return ETAG_EMPTY;
262+
}
263+
return digest.result.digest;
264+
}
265+
}

0 commit comments

Comments
 (0)