1
1
import { RequestHandler , Request , Response } from 'express' ;
2
2
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' ;
5
5
import { asyncHandler } from '../async-handler' ;
6
6
7
7
const CACHE_OK = Symbol ( 'cache_ok' ) ;
8
- const CHAIN_TIP_LOCAL = 'chain_tip' ;
9
8
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
+ */
14
15
const CACHE_CONTROL_MUST_REVALIDATE = 'public, no-cache, must-revalidate' ;
15
16
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 {
17
34
chainTipCacheHits : prom . Counter < string > ;
18
35
chainTipCacheMisses : prom . Counter < string > ;
19
36
chainTipCacheNoHeader : prom . Counter < string > ;
37
+ mempoolCacheHits : prom . Counter < string > ;
38
+ mempoolCacheMisses : prom . Counter < string > ;
39
+ mempoolCacheNoHeader : prom . Counter < string > ;
20
40
}
21
41
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 ;
26
46
}
27
- const metrics : ChainTipCacheMetrics = {
47
+ const metrics : ETagCacheMetrics = {
28
48
chainTipCacheHits : new prom . Counter ( {
29
49
name : 'chain_tip_cache_hits' ,
30
50
help : 'Total count of requests with an up-to-date chain tip cache header' ,
@@ -37,9 +57,21 @@ function getChainTipMetrics(): ChainTipCacheMetrics {
37
57
name : 'chain_tip_cache_no_header' ,
38
58
help : 'Total count of requests that did not provide a chain tip header' ,
39
59
} ) ,
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
+ } ) ,
40
72
} ;
41
- _chainTipMetrics = metrics ;
42
- return _chainTipMetrics ;
73
+ _eTagMetrics = metrics ;
74
+ return _eTagMetrics ;
43
75
}
44
76
45
77
export function setResponseNonCacheable ( res : Response ) {
@@ -48,31 +80,29 @@ export function setResponseNonCacheable(res: Response) {
48
80
}
49
81
50
82
/**
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
52
84
* to the response locals.
53
- * Uses the latest unanchored microblock hash if available, otherwise uses the anchor
54
- * block index hash.
55
85
*/
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 ) {
59
89
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 } ]\`.`
61
91
) ;
62
92
return ;
63
93
}
64
- if ( ! chainTip . found ) {
94
+ if ( etag === ETAG_EMPTY ) {
65
95
return ;
66
96
}
67
- const chainTipTag = chainTip . result . microblockHash ?? chainTip . result . indexBlockHash ;
68
97
res . set ( {
69
98
'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.
71
101
// This value will be provided in the `If-None-Match` request header in subsequent requests.
72
102
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
73
103
// > Entity tag that uniquely represents the requested resource.
74
104
// > It is a string of ASCII characters placed between double quotes..
75
- ETag : `"${ chainTipTag } "` ,
105
+ ETag : `"${ etag } "` ,
76
106
} ) ;
77
107
}
78
108
@@ -123,49 +153,61 @@ export function parseIfNoneMatchHeader(
123
153
}
124
154
125
155
/**
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.
128
159
* 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.
131
162
*/
132
- async function checkChainTipCacheOK (
163
+ async function checkETagCacheOK (
133
164
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 ;
141
172
}
142
173
// Parse ETag values from the request's `If-None-Match` header, if any.
143
174
// Note: node.js normalizes `IncomingMessage.headers` to lowercase.
144
175
const ifNoneMatch = parseIfNoneMatchHeader ( req . headers [ 'if-none-match' ] ) ;
145
176
if ( ifNoneMatch === undefined || ifNoneMatch . length === 0 ) {
146
177
// 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 ;
149
184
}
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
153
187
// 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
+ }
155
193
return CACHE_OK ;
156
194
} 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
158
196
// 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 ;
162
204
}
163
205
}
164
206
165
207
/**
166
208
* 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
169
211
* later use in setting response cache headers.
170
212
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#freshness
171
213
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
@@ -176,21 +218,48 @@ async function checkChainTipCacheOK(
176
218
* doesn't match any of the values listed.
177
219
* ```
178
220
*/
179
- export function getChainTipCacheHandler ( db : DataStore ) : RequestHandler {
221
+ export function getETagCacheHandler (
222
+ db : DataStore ,
223
+ etagType : ETagType = ETagType . chainTip
224
+ ) : RequestHandler {
180
225
const requestHandler = asyncHandler ( async ( req , res , next ) => {
181
- const result = await checkChainTipCacheOK ( db , req ) ;
226
+ const result = await checkETagCacheOK ( db , req , etagType ) ;
182
227
if ( result === CACHE_OK ) {
183
228
// Instruct the client to use the cached response via a `304 Not Modified` response header.
184
229
// This completes the handling for this request, do not call `next()` in order to skip the
185
230
// router handler used for non-cached responses.
186
231
res . set ( 'Cache-Control' , CACHE_CONTROL_MUST_REVALIDATE ) . status ( 304 ) . send ( ) ;
187
232
} 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
189
234
// 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 ;
192
237
next ( ) ;
193
238
}
194
239
} ) ;
195
240
return requestHandler ;
196
241
}
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