Skip to content

Commit 4698598

Browse files
committed
api: memoize shared paid_blocks scan; explicit columns; normalized cache key
- getMinerBlockPayments: the 7-day paid_blocks list is identical for every miner, so memoize it briefly (bounded 64-entry map) instead of re-running the scan for each distinct address. - transactions reads use an explicit column list (never SELECT *) so internal columns (payee address, payment_id) are never selected. - /pool/coin_altblocks cache key uses the normalized integer port so equivalent spellings share one entry.
1 parent 7080cbe commit 4698598

3 files changed

Lines changed: 119 additions & 12 deletions

File tree

lib/api/public.js

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ const workerHistory = require("../common/worker_history");
44
const INVALID_POOL_TYPE = { error: "Invalid pool type" };
55
const PAGE_LIMITS = [15, 50, 100];
66
const MAX_PAGE = 1000;
7+
const PAID_BLOCKS_MEMO_TTL_MS = 10 * 1000;
8+
const PAID_BLOCKS_MEMO_MAX = 64;
9+
// Explicit column list (never SELECT *) so internal columns such as the payee
10+
// address and payment_id are not exposed even if a mapper later changes.
11+
const TRANSACTIONS_SELECT = "SELECT id, transaction_hash, mixin, fees, payees, xmr_amt, submitted_time FROM transactions";
712

813
function defaultTsCompare(left, right) { return left.ts < right.ts ? 1 : left.ts > right.ts ? -1 : 0; }
914

@@ -44,7 +49,7 @@ function safeDecodeAddress(blockTemplate, address) {
4449
module.exports = function registerPublicRoutes(ctx) {
4550
const blockTemplate = ctx.blockTemplate || ctx.cnUtil;
4651
const { core, http, poolList } = ctx;
47-
const { config, database, getCacheValue, query, support } = core;
52+
const { config, database, getCacheValue, now, query, support } = core;
4853
const { registerCachedGet } = http;
4954

5055
function getHashHistory(key) {
@@ -220,14 +225,14 @@ module.exports = function registerPublicRoutes(ctx) {
220225
}
221226

222227
async function getPoolPayments(limit, page) {
223-
const rows = await query("SELECT * FROM transactions ORDER BY id DESC LIMIT ? OFFSET ?", [limit, page * limit]);
228+
const rows = await query(TRANSACTIONS_SELECT + " ORDER BY id DESC LIMIT ? OFFSET ?", [limit, page * limit]);
224229
if (rows.length === 0) return [];
225230

226231
return sortMapped(rows, mapTransactionRow);
227232
}
228233

229234
async function getRecentPayments(limit, page) {
230-
const rows = await query("SELECT * FROM transactions ORDER BY id DESC LIMIT ? OFFSET ?", [limit, page * limit]);
235+
const rows = await query(TRANSACTIONS_SELECT + " ORDER BY id DESC LIMIT ? OFFSET ?", [limit, page * limit]);
231236
if (rows.length === 0) return [];
232237

233238
let poolTypes = {};
@@ -288,12 +293,36 @@ module.exports = function registerPublicRoutes(ctx) {
288293
return sortByTsDesc(response, support);
289294
}
290295

291-
async function getMinerBlockPayments(addressParam, limit, page) {
292-
const parsed = parseAddress(addressParam);
293-
const blocks = await query(
294-
"SELECT * FROM paid_blocks WHERE paid_time > (NOW() - INTERVAL 7 DAY) ORDER BY id DESC LIMIT ? OFFSET ?",
296+
// The recent-paid-blocks list is identical for every miner, so memoize it briefly:
297+
// otherwise each distinct address re-runs the same 7-day paid_blocks scan. The cached
298+
// rows are only read (never mutated) by callers, so sharing the array is safe.
299+
const recentPaidBlocksMemo = new Map();
300+
async function getRecentPaidBlocks(limit, page) {
301+
const memoKey = limit + "|" + page;
302+
const timeNow = typeof now === "function" ? now() : Date.now();
303+
const cached = recentPaidBlocksMemo.get(memoKey);
304+
if (cached && cached.expiresAt > timeNow) return cached.value;
305+
const rows = await query(
306+
"SELECT id, paid_time, found_time, port, hex, amount FROM paid_blocks WHERE paid_time > (NOW() - INTERVAL 7 DAY) ORDER BY id DESC LIMIT ? OFFSET ?",
295307
[limit, page * limit]
296308
);
309+
recentPaidBlocksMemo.set(memoKey, { value: rows, expiresAt: timeNow + PAID_BLOCKS_MEMO_TTL_MS });
310+
if (recentPaidBlocksMemo.size > PAID_BLOCKS_MEMO_MAX) {
311+
for (const [key, entry] of recentPaidBlocksMemo) {
312+
if (entry.expiresAt <= timeNow) recentPaidBlocksMemo.delete(key);
313+
}
314+
while (recentPaidBlocksMemo.size > PAID_BLOCKS_MEMO_MAX) {
315+
const oldest = recentPaidBlocksMemo.keys().next();
316+
if (oldest.done) break;
317+
recentPaidBlocksMemo.delete(oldest.value);
318+
}
319+
}
320+
return rows;
321+
}
322+
323+
async function getMinerBlockPayments(addressParam, limit, page) {
324+
const parsed = parseAddress(addressParam);
325+
const blocks = await getRecentPaidBlocks(limit, page);
297326
if (blocks.length === 0) return [];
298327

299328
const hexes = blocks.map(function map(row) { return row.hex; });
@@ -398,7 +427,7 @@ module.exports = function registerPublicRoutes(ctx) {
398427
})
399428
),
400429
cachedRoute("/pool/coin_altblocks/:coin_port", 10 * 1000, "pool coin altblocks",
401-
pagedCacheKey("pool-coin-altblocks", 15, undefined, function poolCoinAltBlockParts(req) { return [req.params.coin_port]; }),
430+
pagedCacheKey("pool-coin-altblocks", 15, undefined, function poolCoinAltBlockParts(req) { return [normalizeInteger(req.params.coin_port, 0, 1)]; }),
402431
withPagination(15, undefined, function poolCoinAltBlocksRoute(req, pagination) {
403432
const coinPort = normalizeInteger(req.params.coin_port, 0, 1);
404433
if (coinPort === 0) return [];

tests/api/cache_and_payments.js

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ test.describe("api cache and payments", { concurrency: false }, () => {
327327
const config = createConfig();
328328
config.pplns.enable = false;
329329
const mysql = createMysql(async function handler(sql, params, calls) {
330-
if (sql.startsWith("SELECT * FROM transactions ORDER BY id DESC")) {
330+
if (sql.includes("FROM transactions ORDER BY id DESC")) {
331331
return [
332332
{ id: 11, transaction_hash: "hash11", mixin: 12, payees: 1, fees: 2, xmr_amt: 3, submitted_time: "2024-01-02T00:00:00Z" },
333333
{ id: 10, transaction_hash: "hash10", mixin: 11, payees: 2, fees: 3, xmr_amt: 4, submitted_time: "2024-01-01T00:00:00Z" }
@@ -385,7 +385,7 @@ test.describe("api cache and payments", { concurrency: false }, () => {
385385

386386
test("block payment lookups stay parameterized when the wallet path contains injection text", async () => {
387387
const mysql = createMysql(async function handler(sql) {
388-
if (sql.startsWith("SELECT * FROM paid_blocks")) {
388+
if (sql.includes("FROM paid_blocks WHERE")) {
389389
return [
390390
{ id: 1, paid_time: "2024-01-02T00:00:00Z", found_time: "2024-01-01T00:00:00Z", port: 18081, hex: "hex1", amount: 1000 },
391391
{ id: 2, paid_time: "2024-01-03T00:00:00Z", found_time: "2024-01-02T00:00:00Z", port: 18081, hex: "hex2", amount: 2000 }
@@ -414,4 +414,82 @@ test.describe("api cache and payments", { concurrency: false }, () => {
414414
});
415415
});
416416

417+
test("block payment lookups reuse the address-independent paid_blocks scan across miners", async () => {
418+
let paidBlocksCalls = 0;
419+
let balanceCalls = 0;
420+
const mysql = createMysql(async function handler(sql) {
421+
if (sql.includes("FROM paid_blocks WHERE")) {
422+
paidBlocksCalls += 1;
423+
assert.doesNotMatch(sql, /SELECT \*/);
424+
return [{ id: 1, paid_time: "2024-01-02T00:00:00Z", found_time: "2024-01-01T00:00:00Z", port: 18081, hex: "hex1", amount: 1000 }];
425+
}
426+
if (sql.startsWith("SELECT hex, amount FROM block_balance")) {
427+
balanceCalls += 1;
428+
return [{ hex: "hex1", amount: 0.5 }];
429+
}
430+
throw new Error("Unexpected SQL: " + sql);
431+
});
432+
433+
await withRuntime({
434+
blockTemplate: createBlockTemplate(),
435+
config: createConfig(),
436+
database: createDatabase({ caches: {} }),
437+
mysql: mysql,
438+
support: createSupport()
439+
}, async (port) => {
440+
const a = await request(port, { path: "/miner/walletA/block_payments?limit=15&page=0" });
441+
const b = await request(port, { path: "/miner/walletB/block_payments?limit=15&page=0" });
442+
assert.equal(a.statusCode, 200);
443+
assert.equal(b.statusCode, 200);
444+
// The 7-day paid_blocks scan is identical for every miner, so it runs once;
445+
// only the per-miner block_balance lookup repeats.
446+
assert.equal(paidBlocksCalls, 1);
447+
assert.equal(balanceCalls, 2);
448+
});
449+
});
450+
451+
test("pool payments select explicit transaction columns, never SELECT *", async () => {
452+
const seen = [];
453+
const mysql = createMysql(async function handler(sql) {
454+
seen.push(sql);
455+
if (sql.includes("FROM transactions ORDER BY id DESC")) {
456+
return [{ id: 1, transaction_hash: "h1", mixin: 7, payees: 1, fees: 2, xmr_amt: 3, submitted_time: "2024-01-02T00:00:00Z" }];
457+
}
458+
throw new Error("Unexpected SQL: " + sql);
459+
});
460+
461+
await withRuntime({
462+
blockTemplate: createBlockTemplate(),
463+
config: createConfig(),
464+
database: createDatabase({ caches: {} }),
465+
mysql: mysql,
466+
support: createSupport()
467+
}, async (port) => {
468+
const res = await request(port, { path: "/pool/payments/pplns?limit=15&page=0" });
469+
assert.equal(res.statusCode, 200);
470+
const txnSql = seen.find((sql) => sql.includes("FROM transactions ORDER BY id DESC"));
471+
assert.ok(txnSql, "transactions query ran");
472+
assert.doesNotMatch(txnSql, /SELECT \*/);
473+
assert.doesNotMatch(txnSql, /\baddress\b/);
474+
assert.doesNotMatch(txnSql, /payment_id/);
475+
});
476+
});
477+
478+
test("coin altblocks cache key normalizes the port so equivalent spellings share one entry", async () => {
479+
const database = createDatabase({ caches: {} });
480+
await withRuntime({
481+
blockTemplate: createBlockTemplate(),
482+
config: createConfig(),
483+
database: database,
484+
mysql: createMysql(async () => []),
485+
support: createSupport()
486+
}, async (port) => {
487+
await request(port, { path: "/pool/coin_altblocks/18081?page=0" });
488+
await request(port, { path: "/pool/coin_altblocks/018081?page=0" });
489+
// Both spellings normalize to port 18081 -> one cache entry -> one DB call.
490+
assert.equal(database.state.altBlockListCalls.length, 1);
491+
assert.equal(database.state.altBlockListCalls[0].coinPort, 18081);
492+
});
493+
});
494+
417495
});

tests/api/public_and_auth.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ test.describe("api public and auth", { concurrency: false }, () => {
516516

517517
test("GUI-facing public routes keep nested stats, field names, and timestamp units", async () => {
518518
const mysql = createMysql(async function handler(sql, params) {
519-
if (sql.startsWith("SELECT * FROM transactions ORDER BY id DESC")) {
519+
if (sql.includes("FROM transactions ORDER BY id DESC")) {
520520
return [
521521
{ id: 44, transaction_hash: "pool-tx", mixin: 11, payees: 3, fees: 15, xmr_amt: 345, submitted_time: "2024-01-02T03:04:05Z" }
522522
];
@@ -531,7 +531,7 @@ test.describe("api public and auth", { concurrency: false }, () => {
531531
{ id: 55, transaction_hash: "miner-tx", mixin: 7 }
532532
];
533533
}
534-
if (sql.startsWith("SELECT * FROM paid_blocks")) {
534+
if (sql.includes("FROM paid_blocks WHERE")) {
535535
return [
536536
{ id: 8, paid_time: "2024-01-03T00:00:00Z", found_time: "2024-01-02T00:00:00Z", port: 18081, hex: "block-hex", amount: 400 }
537537
];

0 commit comments

Comments
 (0)