Skip to content

Commit 84c3c07

Browse files
authored
Merge pull request #706 from Baskarayelu/feat/indexed-reads-673-etag
perf(cache): add ETags to indexed read responses
2 parents bd77f56 + 5ac4b0a commit 84c3c07

4 files changed

Lines changed: 92 additions & 12 deletions

File tree

src/routes/read.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,14 @@
3131
* handlers — they remain exported and documented for future use.
3232
*/
3333
import { Router } from "express";
34-
import type { Request } from "express";
34+
import type { Request, Response } from "express";
3535
import { z } from "zod";
3636
import { shortString } from "starknet";
3737
import { agreementContract, escrowContract, provider } from "../starknet/client.js";
3838
import { u256ToString, toHexString } from "../utils/codec.js";
3939
import { env } from "../config.js";
4040
import { NumericCursorSchema, loggedParse } from "../utils/validation.js";
41+
import { applyIndexedCacheHeaders } from "../utils/cache-headers.js";
4142

4243
// ---------- validation ----------
4344

@@ -379,6 +380,16 @@ function logReadTelemetry(entry: TelemetryEntry) {
379380

380381
export const readRouter = Router();
381382

383+
/** Send a read-only indexed response with conditional-request support. */
384+
function sendIndexedResponse(req: Request, res: Response, body: unknown): void {
385+
applyIndexedCacheHeaders(res, body);
386+
if (req.fresh) {
387+
res.status(304).end();
388+
return;
389+
}
390+
res.json(body);
391+
}
392+
382393
// ---------- token / balances ----------
383394

384395
readRouter.get("/token/:token/balance/:owner", async (req, res, next) => {
@@ -388,7 +399,7 @@ readRouter.get("/token/:token/balance/:owner", async (req, res, next) => {
388399
const token = AddressParam.parse(req.params.token);
389400
const owner = AddressParam.parse(req.params.owner);
390401
const balance = await erc20BalanceOf(token, owner);
391-
res.json({ token, owner, balance });
402+
sendIndexedResponse(req, res, { token, owner, balance });
392403
} catch (e: any) {
393404
const duration = Number(process.hrtime.bigint() - start) / 1_000_000;
394405
logReadTelemetry({
@@ -427,7 +438,7 @@ readRouter.get("/token/:token/decimals", async (req, res, next) => {
427438
token,
428439
request_id: res.locals.requestId,
429440
});
430-
res.json({ token, decimals });
441+
sendIndexedResponse(req, res, { token, decimals });
431442
} catch (e: any) {
432443
const duration = Number(process.hrtime.bigint() - start) / 1_000_000;
433444
logReadTelemetry({
@@ -465,7 +476,7 @@ readRouter.get("/token/:token/symbol", async (req, res, next) => {
465476
token,
466477
request_id: res.locals.requestId,
467478
});
468-
res.json({ token, symbol });
479+
sendIndexedResponse(req, res, { token, symbol });
469480
} catch (e: any) {
470481
const duration = Number(process.hrtime.bigint() - start) / 1_000_000;
471482
logReadTelemetry({
@@ -509,7 +520,7 @@ readRouter.get("/escrow/:address/balance/:agreement_id", async (req, res, next)
509520
agreement_id: agreement_id.toString(),
510521
request_id: requestId,
511522
});
512-
res.json({
523+
sendIndexedResponse(req, res, {
513524
escrow: escrowAddress,
514525
agreement_id: agreement_id.toString(),
515526
balance: u256ToString(balance),
@@ -562,7 +573,7 @@ readRouter.get("/escrow/:address/summary/:agreement_id", async (req, res, next)
562573
agreement_id: agreement_id.toString(),
563574
request_id: requestId,
564575
});
565-
res.json({
576+
sendIndexedResponse(req, res, {
566577
escrow: escrowAddress,
567578
agreement_id: agreement_id.toString(),
568579
employer: toHexString(employer),
@@ -622,7 +633,7 @@ readRouter.get("/agreement/:address/summary/:agreement_id", async (req, res, nex
622633
agreement_id: agreement_id.toString(),
623634
request_id: requestId,
624635
});
625-
res.json({
636+
sendIndexedResponse(req, res, {
626637
agreement: agreementAddress,
627638
agreement_id: agreement_id.toString(),
628639
employer: toHexString(employer),

src/routes/transactions.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
* the user **is** the employee (`employee-only`).
2626
*/
2727
import { Router } from "express";
28+
import type { Request, Response } from "express";
2829
import { z } from "zod";
2930
import { db, schema } from "../db/index.js";
3031
import { eq, and, or, desc, gte, lte, inArray, sql, count } from "drizzle-orm";
@@ -37,6 +38,7 @@ import {
3738
getTokenInfo as resolveTokenInfo,
3839
type TokenInfo,
3940
} from "../utils/token-formatting.js";
41+
import { applyIndexedCacheHeaders } from "../utils/cache-headers.js";
4042

4143
// ── Types ────────────────────────────────────────────────────────────────
4244

@@ -1230,7 +1232,8 @@ function applySort(
12301232
* - `limit` / `offset`: the requested parameters (clamped)
12311233
*/
12321234
function respondPaginated(
1233-
res: import("express").Response,
1235+
req: Request,
1236+
res: Response,
12341237
allTransactions: TransactionItem[],
12351238
total: number,
12361239
limit: number,
@@ -1248,6 +1251,11 @@ function respondPaginated(
12481251
limit,
12491252
offset,
12501253
};
1254+
applyIndexedCacheHeaders(res, body);
1255+
if (req.fresh) {
1256+
res.status(304).end();
1257+
return;
1258+
}
12511259
res.json(body);
12521260
}
12531261

@@ -1301,7 +1309,7 @@ transactionsRouter.get(
13011309
{ deduplicateAgreementEvents: true },
13021310
);
13031311

1304-
respondPaginated(res, allTransactions, total, limit, offset, sortBy, sortDir);
1312+
respondPaginated(req, res, allTransactions, total, limit, offset, sortBy, sortDir);
13051313
} catch (e: any) {
13061314
if (e?.status === 400 && e?.body) {
13071315
res.status(400).json(e.body);
@@ -1354,7 +1362,7 @@ transactionsRouter.get(
13541362
offset + limit,
13551363
);
13561364

1357-
respondPaginated(res, allTransactions, total, limit, offset, sortBy, sortDir);
1365+
respondPaginated(req, res, allTransactions, total, limit, offset, sortBy, sortDir);
13581366
} catch (e: any) {
13591367
if (e?.status === 400 && e?.body) {
13601368
res.status(400).json(e.body);

src/utils/address.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import { getChecksumAddress } from "starknet";
2-
import { describe, expect, it } from "vitest";
3-
import { normalizeStarknetAddress } from "./address.js";
2+
import { beforeEach, describe, expect, it } from "vitest";
3+
import {
4+
clearAddressNormalizationCache,
5+
normalizeStarknetAddress,
6+
} from "./address.js";
47

58
const zeroAddress = `0x${"0".repeat(64)}`;
69
const oneAddress = `0x${"0".repeat(63)}1`;
710
const fullAddress = `0x${"a".repeat(64)}`;
811

912
describe("normalizeStarknetAddress", () => {
13+
beforeEach(() => {
14+
clearAddressNormalizationCache();
15+
});
16+
1017
it("normalizes missing prefixes, mixed case, whitespace, and short values", () => {
1118
expect(normalizeStarknetAddress("1")).toBe(oneAddress);
1219
expect(normalizeStarknetAddress(" 0XABC ")).toBe(`0x${"0".repeat(61)}abc`);
@@ -49,6 +56,22 @@ describe("normalizeStarknetAddress", () => {
4956
expect(() => normalizeStarknetAddress(`0x1${"0".repeat(64)}`)).toThrow(/exceeds/);
5057
});
5158

59+
it("reuses normalized values for repeated inputs", () => {
60+
const first = normalizeStarknetAddress("0x0001");
61+
const second = normalizeStarknetAddress("0x0001");
62+
expect(second).toBe(first);
63+
});
64+
65+
it("bounds cached entries while retaining the newest value", () => {
66+
for (let index = 0; index < 1_025; index += 1) {
67+
normalizeStarknetAddress(`0x${index.toString(16)}`);
68+
}
69+
70+
expect(normalizeStarknetAddress("0x400")).toBe(
71+
`0x${"0".repeat(61)}400`,
72+
);
73+
});
74+
5275
describe("checksum validation", () => {
5376
const sampleAddress = "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7";
5477
const checksummed = getChecksumAddress(sampleAddress);

src/utils/address.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,40 @@
11
import { getChecksumAddress } from "starknet";
22

33
const STARKNET_ADDRESS_HEX_LENGTH = 64;
4+
const ADDRESS_NORMALIZATION_CACHE_MAX_ENTRIES = 1_024;
5+
6+
/**
7+
* Recently normalized inputs. Address normalization is on a number of hot
8+
* request paths, and the same contract/token addresses are commonly parsed
9+
* repeatedly while building one response. A Map gives us a small LRU without
10+
* introducing a dependency or allowing unbounded user-controlled growth.
11+
*/
12+
const normalizedAddressCache = new Map<string, string>();
13+
14+
/** Clear the address cache between tests or configuration reloads. */
15+
export function clearAddressNormalizationCache(): void {
16+
normalizedAddressCache.clear();
17+
}
18+
19+
function getCachedAddress(input: string): string | undefined {
20+
const cached = normalizedAddressCache.get(input);
21+
if (cached === undefined) return undefined;
22+
23+
// Refresh recency whenever an entry is used.
24+
normalizedAddressCache.delete(input);
25+
normalizedAddressCache.set(input, cached);
26+
return cached;
27+
}
28+
29+
function cacheAddress(input: string, normalized: string): void {
30+
normalizedAddressCache.delete(input);
31+
normalizedAddressCache.set(input, normalized);
32+
while (normalizedAddressCache.size > ADDRESS_NORMALIZATION_CACHE_MAX_ENTRIES) {
33+
const oldest = normalizedAddressCache.keys().next().value;
34+
if (oldest === undefined) break;
35+
normalizedAddressCache.delete(oldest);
36+
}
37+
}
438

539
/**
640
* Normalize a Starknet address to the canonical database lookup key.
@@ -28,6 +62,9 @@ export function normalizeStarknetAddress(address: string): string {
2862
if (!trimmed) {
2963
throw new Error("Starknet address is required");
3064
}
65+
const cached = getCachedAddress(trimmed);
66+
if (cached !== undefined) return cached;
67+
3168
const normalized = trimmed.toLowerCase();
3269
const prefixed = normalized.startsWith("0x") ? normalized : `0x${normalized}`;
3370
const hex = prefixed.replace(/^0x/, "");
@@ -51,5 +88,6 @@ export function normalizeStarknetAddress(address: string): string {
5188
}
5289
}
5390

91+
cacheAddress(trimmed, canonical);
5492
return canonical;
5593
}

0 commit comments

Comments
 (0)