• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hirosystems / stacks-blockchain-api / 3749978634

pending completion
3749978634

push

github

GitHub
feat: [Stacks 2.1] Support new "block 0" boot events (#1476)

2014 of 3089 branches covered (65.2%)

13 of 13 new or added lines in 1 file covered. (100.0%)

886 existing lines in 37 files now uncovered.

7070 of 9217 relevant lines covered (76.71%)

970.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.07
/src/api/controllers/cache-controller.ts
1
import { RequestHandler, Request, Response } from 'express';
2
import * as prom from 'prom-client';
31✔
3
import { bufferToHexPrefixString, logger, normalizeHashString } from '../../helpers';
31✔
4
import { asyncHandler } from '../async-handler';
31✔
5
import { PgStore } from '../../datastore/pg-store';
6

7
const CACHE_OK = Symbol('cache_ok');
31✔
8

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
 */
15
const CACHE_CONTROL_MUST_REVALIDATE = 'public, no-cache, must-revalidate';
31✔
16

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 {
31✔
23
  /** ETag based on the latest `index_block_hash` or `microblock_hash`. */
24
  chainTip = 'chain_tip',
31✔
25
  /** ETag based on a digest of all pending mempool `tx_id`s. */
26
  mempool = 'mempool',
31✔
27
  /** ETag based on the status of a single transaction across the mempool or canonical chain. */
28
  transaction = 'transaction',
31✔
29
}
30

31
/** Value that means the ETag did get calculated but it is empty. */
32
const ETAG_EMPTY = Symbol(-1);
31✔
33
type ETag = string | typeof ETAG_EMPTY;
34

35
interface ETagCacheMetrics {
36
  chainTipCacheHits: prom.Counter<string>;
37
  chainTipCacheMisses: prom.Counter<string>;
38
  chainTipCacheNoHeader: prom.Counter<string>;
39
  mempoolCacheHits: prom.Counter<string>;
40
  mempoolCacheMisses: prom.Counter<string>;
41
  mempoolCacheNoHeader: prom.Counter<string>;
42
}
43

44
let _eTagMetrics: ETagCacheMetrics | undefined;
45
function getETagMetrics(): ETagCacheMetrics {
46
  if (_eTagMetrics !== undefined) {
293✔
47
    return _eTagMetrics;
281✔
48
  }
49
  const metrics: ETagCacheMetrics = {
12✔
50
    chainTipCacheHits: new prom.Counter({
51
      name: 'chain_tip_cache_hits',
52
      help: 'Total count of requests with an up-to-date chain tip cache header',
53
    }),
54
    chainTipCacheMisses: new prom.Counter({
55
      name: 'chain_tip_cache_misses',
56
      help: 'Total count of requests with a stale chain tip cache header',
57
    }),
58
    chainTipCacheNoHeader: new prom.Counter({
59
      name: 'chain_tip_cache_no_header',
60
      help: 'Total count of requests that did not provide a chain tip header',
61
    }),
62
    mempoolCacheHits: new prom.Counter({
63
      name: 'mempool_cache_hits',
64
      help: 'Total count of requests with an up-to-date mempool cache header',
65
    }),
66
    mempoolCacheMisses: new prom.Counter({
67
      name: 'mempool_cache_misses',
68
      help: 'Total count of requests with a stale mempool cache header',
69
    }),
70
    mempoolCacheNoHeader: new prom.Counter({
71
      name: 'mempool_cache_no_header',
72
      help: 'Total count of requests that did not provide a mempool header',
73
    }),
74
  };
75
  _eTagMetrics = metrics;
12✔
76
  return _eTagMetrics;
12✔
77
}
78

79
export function setResponseNonCacheable(res: Response) {
31✔
80
  res.removeHeader('Cache-Control');
18✔
81
  res.removeHeader('ETag');
18✔
82
}
83

84
/**
85
 * Sets the response `Cache-Control` and `ETag` headers using the etag previously added
86
 * to the response locals.
87
 */
88
export function setETagCacheHeaders(res: Response, etagType: ETagType = ETagType.chainTip) {
31✔
89
  const etag: ETag | undefined = res.locals[etagType];
240✔
90
  if (!etag) {
240✔
91
    logger.error(
19✔
92
      `Cannot set cache control headers, no etag was set on \`Response.locals[${etagType}]\`.`
93
    );
94
    return;
19✔
95
  }
96
  if (etag === ETAG_EMPTY) {
221!
97
    return;
×
98
  }
99
  res.set({
221✔
100
    'Cache-Control': CACHE_CONTROL_MUST_REVALIDATE,
101
    // Use the current chain tip or mempool state as the etag so that cache is invalidated on new blocks or
102
    // new mempool events.
103
    // This value will be provided in the `If-None-Match` request header in subsequent requests.
104
    // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
105
    // > Entity tag that uniquely represents the requested resource.
106
    // > It is a string of ASCII characters placed between double quotes..
107
    ETag: `"${etag}"`,
108
  });
109
}
110

111
/**
112
 * Parses the etag values from a raw `If-None-Match` request header value.
113
 * The wrapping double quotes (if any) and validation prefix (if any) are stripped.
114
 * The parsing is permissive to account for commonly non-spec-compliant clients, proxies, CDNs, etc.
115
 * E.g. the value:
116
 * ```js
117
 * `"a", W/"b", c,d,   "e", "f"`
118
 * ```
119
 * Would be parsed and returned as:
120
 * ```js
121
 * ['a', 'b', 'c', 'd', 'e', 'f']
122
 * ```
123
 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match#syntax
124
 * ```
125
 * If-None-Match: "etag_value"
126
 * If-None-Match: "etag_value", "etag_value", ...
127
 * If-None-Match: *
128
 * ```
129
 * @param ifNoneMatchHeaderValue - raw header value
130
 * @returns an array of etag values
131
 */
132
export function parseIfNoneMatchHeader(
31✔
133
  ifNoneMatchHeaderValue: string | undefined
134
): string[] | undefined {
135
  if (!ifNoneMatchHeaderValue) {
256✔
136
    return undefined;
230✔
137
  }
138
  // Strip wrapping double quotes like `"hello"` and the ETag validation-prefix like `W/"hello"`.
139
  // The API returns compliant, strong-validation ETags (double quoted ASCII), but can't control what
140
  // clients, proxies, CDNs, etc may provide.
141
  const normalized = /^(?:"|W\/")?(.*?)"?$/gi.exec(ifNoneMatchHeaderValue.trim())?.[1];
26✔
142
  if (!normalized) {
26✔
143
    // This should never happen unless handling a buggy request with something like `If-None-Match: ""`,
144
    // or if there's a flaw in the above code. Log warning for now.
145
    logger.warn(`Normalized If-None-Match header is falsy: ${ifNoneMatchHeaderValue}`);
1✔
146
    return undefined;
1✔
147
  } else if (normalized.includes(',')) {
25✔
148
    // Multiple etag values provided, likely irrelevant extra values added by a proxy/CDN.
149
    // Split on comma, also stripping quotes, weak-validation prefixes, and extra whitespace.
150
    return normalized.split(/(?:W\/"|")?(?:\s*),(?:\s*)(?:W\/"|")?/gi);
5✔
151
  } else {
152
    // Single value provided (the typical case)
153
    return [normalized];
20✔
154
  }
155
}
156

157
/**
158
 * Parse the `ETag` from the given request's `If-None-Match` header which represents the chain tip or
159
 * mempool state associated with the client's cached response. Query the current state from the db, and
160
 * compare the two.
161
 * This function is also responsible for tracking the prometheus metrics associated with cache hits/misses.
162
 * @returns `CACHE_OK` if the client's cached response is up-to-date with the current state, otherwise,
163
 * returns a string which can be used later for setting the cache control `ETag` response header.
164
 */
165
async function checkETagCacheOK(
166
  db: PgStore,
167
  req: Request,
168
  etagType: ETagType
169
): Promise<ETag | undefined | typeof CACHE_OK> {
170
  const metrics = getETagMetrics();
293✔
171
  const etag = await calculateETag(db, etagType, req);
293✔
172
  if (!etag || etag === ETAG_EMPTY) {
293✔
173
    return;
48✔
174
  }
175
  // Parse ETag values from the request's `If-None-Match` header, if any.
176
  // Note: node.js normalizes `IncomingMessage.headers` to lowercase.
177
  const ifNoneMatch = parseIfNoneMatchHeader(req.headers['if-none-match']);
245✔
178
  if (ifNoneMatch === undefined || ifNoneMatch.length === 0) {
245✔
179
    // No if-none-match header specified.
180
    if (etagType === ETagType.chainTip) {
228✔
181
      metrics.chainTipCacheNoHeader.inc();
184✔
182
    } else {
183
      metrics.mempoolCacheNoHeader.inc();
44✔
184
    }
185
    return etag;
228✔
186
  }
187
  if (ifNoneMatch.includes(etag)) {
17✔
188
    // The client cache's ETag matches the current state, so no need to re-process the request
189
    // server-side as there will be no change in response. Record this as a "cache hit" and return CACHE_OK.
190
    if (etagType === ETagType.chainTip) {
8✔
191
      metrics.chainTipCacheHits.inc();
3✔
192
    } else {
193
      metrics.mempoolCacheHits.inc();
5✔
194
    }
195
    return CACHE_OK;
8✔
196
  } else {
197
    // The client cache's ETag is associated with an different block than current latest state, typically
198
    // an older block or a forked block, so the client's cached response is stale and should not be used.
199
    // Record this as a "cache miss" and return the current state.
200
    if (etagType === ETagType.chainTip) {
9✔
201
      metrics.chainTipCacheMisses.inc();
2✔
202
    } else {
203
      metrics.mempoolCacheMisses.inc();
7✔
204
    }
205
    return etag;
9✔
206
  }
207
}
208

209
/**
210
 * Check if the request has an up-to-date cached response by comparing the `If-None-Match` request header to the
211
 * current state. If the cache is valid then a `304 Not Modified` response is sent and the route handling for
212
 * this request is completed. If the cache is outdated, the current state is added to the `Request.locals` for
213
 * later use in setting response cache headers.
214
 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#freshness
215
 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
216
 * ```md
217
 * The If-None-Match HTTP request header makes the request conditional. For GET and HEAD methods, the server
218
 * will return the requested resource, with a 200 status, only if it doesn't have an ETag matching the given
219
 * ones. For other methods, the request will be processed only if the eventually existing resource's ETag
220
 * doesn't match any of the values listed.
221
 * ```
222
 */
223
export function getETagCacheHandler(
31✔
224
  db: PgStore,
225
  etagType: ETagType = ETagType.chainTip
1,632✔
226
): RequestHandler {
227
  const requestHandler = asyncHandler(async (req, res, next) => {
2,244✔
228
    const result = await checkETagCacheOK(db, req, etagType);
293✔
229
    if (result === CACHE_OK) {
293✔
230
      // Instruct the client to use the cached response via a `304 Not Modified` response header.
231
      // This completes the handling for this request, do not call `next()` in order to skip the
232
      // router handler used for non-cached responses.
233
      res.set('Cache-Control', CACHE_CONTROL_MUST_REVALIDATE).status(304).send();
8✔
234
    } else {
235
      // Request does not have a valid cache. Store the etag for later
236
      // use in setting response cache headers.
237
      const etag: ETag | undefined = result;
285✔
238
      res.locals[etagType] = etag;
285✔
239
      next();
285✔
240
    }
241
  });
242
  return requestHandler;
2,244✔
243
}
244

245
async function calculateETag(
246
  db: PgStore,
247
  etagType: ETagType,
248
  req: Request
249
): Promise<ETag | undefined> {
250
  switch (etagType) {
293✔
251
    case ETagType.chainTip:
252
      try {
224✔
253
        const chainTip = await db.getUnanchoredChainTip();
224✔
254
        if (!chainTip.found) {
223✔
255
          // This should never happen unless the API is serving requests before it has synced any
256
          // blocks.
257
          return;
34✔
258
        }
259
        return chainTip.result.microblockHash ?? chainTip.result.indexBlockHash;
189✔
260
      } catch (error) {
261
        logger.error(`Unable to calculate chain_tip ETag: ${error}`);
1✔
262
        return;
1✔
263
      }
264

265
    case ETagType.mempool:
266
      try {
27✔
267
        const digest = await db.getMempoolTxDigest();
27✔
268
        if (!digest.found) {
27!
269
          // This should never happen unless the API is serving requests before it has synced any
270
          // blocks.
UNCOV
271
          return;
×
272
        }
273
        if (digest.result.digest === null) {
27✔
274
          // A `null` mempool digest means the `bit_xor` postgres function is unavailable.
275
          return ETAG_EMPTY;
1✔
276
        }
277
        return digest.result.digest;
26✔
278
      } catch (error) {
UNCOV
279
        logger.error(`Unable to calculate mempool ETag: ${error}`);
×
UNCOV
280
        return;
×
281
      }
282

283
    case ETagType.transaction:
284
      try {
42✔
285
        const { tx_id } = req.params;
42✔
286
        const normalizedTxId = normalizeHashString(tx_id);
42✔
287
        if (normalizedTxId === false) {
42✔
288
          return ETAG_EMPTY;
10✔
289
        }
290
        const status = await db.getTxStatus(normalizedTxId);
32✔
291
        if (!status.found) {
32✔
292
          return ETAG_EMPTY;
2✔
293
        }
294
        const elements: string[] = [
30✔
295
          normalizedTxId,
296
          status.result.index_block_hash ?? '',
44✔
297
          status.result.microblock_hash ?? '',
44✔
298
          status.result.status.toString(),
299
        ];
300
        return elements.join(':');
30✔
301
      } catch (error) {
UNCOV
302
        logger.error(`Unable to calculate transaction ETag: ${error}`);
×
UNCOV
303
        return;
×
304
      }
305
  }
306
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc