mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
Cache hot API responses in Redis
This commit is contained in:
+32
-9
@@ -12,6 +12,7 @@ import { BitcoinRpcService } from './services/bitcoin-rpc.service';
|
|||||||
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
|
import { UserAgentReportView } from './ORM/_views/user-agent-report/user-agent-report.view';
|
||||||
import { StratumV2Service } from './services/stratum-v2.service';
|
import { StratumV2Service } from './services/stratum-v2.service';
|
||||||
import { ShareAccountingService } from './ORM/share-accounting/share-accounting.service';
|
import { ShareAccountingService } from './ORM/share-accounting/share-accounting.service';
|
||||||
|
import { RedisMessagingService } from './services/redis-messaging.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
@@ -27,7 +28,8 @@ export class AppController {
|
|||||||
private readonly addressSettingsService: AddressSettingsService,
|
private readonly addressSettingsService: AddressSettingsService,
|
||||||
private readonly userAgentReportService: UserAgentReportService,
|
private readonly userAgentReportService: UserAgentReportService,
|
||||||
private readonly stratumV2Service: StratumV2Service,
|
private readonly stratumV2Service: StratumV2Service,
|
||||||
private readonly shareAccountingService: ShareAccountingService
|
private readonly shareAccountingService: ShareAccountingService,
|
||||||
|
private readonly redisMessagingService: RedisMessagingService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@Get('info')
|
@Get('info')
|
||||||
@@ -35,7 +37,7 @@ export class AppController {
|
|||||||
|
|
||||||
|
|
||||||
const CACHE_KEY = 'SITE_INFO';
|
const CACHE_KEY = 'SITE_INFO';
|
||||||
const cachedResult = await this.cacheManager.get(CACHE_KEY);
|
const cachedResult = await this.getCached(CACHE_KEY);
|
||||||
|
|
||||||
if (cachedResult != null) {
|
if (cachedResult != null) {
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
@@ -86,7 +88,7 @@ export class AppController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Keep online miner counts responsive after reconnect cleanup.
|
// Keep online miner counts responsive after reconnect cleanup.
|
||||||
await this.cacheManager.set(CACHE_KEY, data, 15 * 1000);
|
await this.setCached(CACHE_KEY, data, 15 * 1000);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|
||||||
@@ -95,7 +97,7 @@ export class AppController {
|
|||||||
@Get('info/accounting')
|
@Get('info/accounting')
|
||||||
public async infoAccounting() {
|
public async infoAccounting() {
|
||||||
const CACHE_KEY = 'SITE_ACCOUNTING';
|
const CACHE_KEY = 'SITE_ACCOUNTING';
|
||||||
const cachedResult = await this.cacheManager.get(CACHE_KEY);
|
const cachedResult = await this.getCached(CACHE_KEY);
|
||||||
|
|
||||||
if (cachedResult != null) {
|
if (cachedResult != null) {
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
@@ -104,7 +106,7 @@ export class AppController {
|
|||||||
const data = await this.shareAccountingService.getPoolSummary();
|
const data = await this.shareAccountingService.getPoolSummary();
|
||||||
|
|
||||||
//15 sec
|
//15 sec
|
||||||
await this.cacheManager.set(CACHE_KEY, data, 15 * 1000);
|
await this.setCached(CACHE_KEY, data, 15 * 1000);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -113,7 +115,7 @@ export class AppController {
|
|||||||
public async pool() {
|
public async pool() {
|
||||||
|
|
||||||
const CACHE_KEY = 'POOL_INFO';
|
const CACHE_KEY = 'POOL_INFO';
|
||||||
const cachedResult = await this.cacheManager.get(CACHE_KEY);
|
const cachedResult = await this.getCached(CACHE_KEY);
|
||||||
|
|
||||||
if (cachedResult != null) {
|
if (cachedResult != null) {
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
@@ -135,7 +137,7 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Keep online miner counts responsive after reconnect cleanup.
|
// Keep online miner counts responsive after reconnect cleanup.
|
||||||
await this.cacheManager.set(CACHE_KEY, data, 15 * 1000);
|
await this.setCached(CACHE_KEY, data, 15 * 1000);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -150,7 +152,7 @@ export class AppController {
|
|||||||
|
|
||||||
|
|
||||||
const CACHE_KEY = 'SITE_HASHRATE_GRAPH';
|
const CACHE_KEY = 'SITE_HASHRATE_GRAPH';
|
||||||
const cachedResult = await this.cacheManager.get(CACHE_KEY);
|
const cachedResult = await this.getCached(CACHE_KEY);
|
||||||
|
|
||||||
if (cachedResult != null) {
|
if (cachedResult != null) {
|
||||||
return cachedResult;
|
return cachedResult;
|
||||||
@@ -159,11 +161,32 @@ export class AppController {
|
|||||||
const chartData = await this.clientStatisticsService.getChartDataForSite();
|
const chartData = await this.clientStatisticsService.getChartDataForSite();
|
||||||
|
|
||||||
//10 min
|
//10 min
|
||||||
await this.cacheManager.set(CACHE_KEY, chartData, 10 * 60 * 1000);
|
await this.setCached(CACHE_KEY, chartData, 10 * 60 * 1000);
|
||||||
|
|
||||||
return chartData;
|
return chartData;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getCached<T>(key: string): Promise<T | null> {
|
||||||
|
const shared = await this.redisMessagingService.getJsonCache<T>(`api:${key}`).catch(error => {
|
||||||
|
console.error(`Shared API cache read failed for ${key}: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (shared != null) {
|
||||||
|
return shared;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.cacheManager.get<T>(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setCached(key: string, value: unknown, ttlMs: number): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this.cacheManager.set(key, value, ttlMs),
|
||||||
|
this.redisMessagingService.setJsonCache(`api:${key}`, value, ttlMs).catch(error => {
|
||||||
|
console.error(`Shared API cache write failed for ${key}: ${error.message}`);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ function createRedisClient() {
|
|||||||
return Promise.resolve('OK');
|
return Promise.resolve('OK');
|
||||||
}),
|
}),
|
||||||
get: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)),
|
get: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)),
|
||||||
|
mGet: jest.fn((keys: string[]) => Promise.resolve(keys.map(key => store.get(key) ?? null))),
|
||||||
del: jest.fn((...args: (string | string[])[]) => {
|
del: jest.fn((...args: (string | string[])[]) => {
|
||||||
const keys = args.flatMap(key => Array.isArray(key) ? key : [key]);
|
const keys = args.flatMap(key => Array.isArray(key) ? key : [key]);
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
@@ -148,9 +149,13 @@ function createRedisClient() {
|
|||||||
sets.set(key, set);
|
sets.set(key, set);
|
||||||
return Promise.resolve(1);
|
return Promise.resolve(1);
|
||||||
}),
|
}),
|
||||||
sRem: jest.fn((key: string, value: string) => {
|
sRem: jest.fn((key: string, value: string | string[]) => {
|
||||||
const set = sets.get(key);
|
const set = sets.get(key);
|
||||||
const deleted = set?.delete(value) ? 1 : 0;
|
const values = Array.isArray(value) ? value : [value];
|
||||||
|
let deleted = 0;
|
||||||
|
values.forEach(item => {
|
||||||
|
deleted += set?.delete(item) ? 1 : 0;
|
||||||
|
});
|
||||||
return Promise.resolve(deleted);
|
return Promise.resolve(deleted);
|
||||||
}),
|
}),
|
||||||
sMembers: jest.fn((key: string) => Promise.resolve([...sets.get(key) ?? []])),
|
sMembers: jest.fn((key: string) => Promise.resolve([...sets.get(key) ?? []])),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const blockTemplateKey = (height: number) => `block-template:${height}`;
|
|||||||
const CLIENT_PRESENCE_ALL_KEY = 'client-presence:all';
|
const CLIENT_PRESENCE_ALL_KEY = 'client-presence:all';
|
||||||
const clientPresenceKey = (clientId: string) => `client-presence:${clientId}`;
|
const clientPresenceKey = (clientId: string) => `client-presence:${clientId}`;
|
||||||
const clientPresenceAddressKey = (address: string) => `client-presence:address:${address}`;
|
const clientPresenceAddressKey = (address: string) => `client-presence:address:${address}`;
|
||||||
|
const jsonCacheKey = (key: string) => `json-cache:${key}`;
|
||||||
|
|
||||||
export interface ClientPresence {
|
export interface ClientPresence {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -218,6 +219,37 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
|||||||
await this.deleteKeys(batch);
|
await this.deleteKeys(batch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getJsonCache<T>(key: string): Promise<T | null> {
|
||||||
|
if (!await this.ensureConnected()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = await this.publisher.get(jsonCacheKey(key));
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value as string) as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Invalid Redis JSON cache for ${key}: ${error.message}`);
|
||||||
|
await this.publisher.del(jsonCacheKey(key));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setJsonCache(key: string, value: unknown, ttlMs: number): Promise<void> {
|
||||||
|
if (!await this.ensureConnected() || ttlMs <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.publisher.setEx(
|
||||||
|
jsonCacheKey(key),
|
||||||
|
Math.max(1, Math.ceil(ttlMs / 1000)),
|
||||||
|
JSON.stringify(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureConnected(): Promise<boolean> {
|
private async ensureConnected(): Promise<boolean> {
|
||||||
if (!this.connected) {
|
if (!this.connected) {
|
||||||
try {
|
try {
|
||||||
@@ -236,18 +268,33 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const presences = await Promise.all(clientIds.map(async clientId => {
|
const presences: ClientPresence[] = [];
|
||||||
const presence = await this.getClientPresence(clientId);
|
const staleClientIds: string[] = [];
|
||||||
if (presence == null) {
|
for (let i = 0; i < clientIds.length; i += 1000) {
|
||||||
await this.publisher.sRem(setKey, clientId);
|
const chunk = clientIds.slice(i, i + 1000);
|
||||||
if (setKey !== CLIENT_PRESENCE_ALL_KEY) {
|
const values = await this.publisher.mGet(chunk.map(clientPresenceKey));
|
||||||
await this.publisher.sRem(CLIENT_PRESENCE_ALL_KEY, clientId);
|
values.forEach((value, index) => {
|
||||||
|
const presence = this.parseClientPresence(value);
|
||||||
|
if (presence == null) {
|
||||||
|
staleClientIds.push(chunk[index]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
presences.push(presence);
|
||||||
return presence;
|
});
|
||||||
}));
|
}
|
||||||
|
|
||||||
return presences.filter((presence): presence is ClientPresence => presence != null);
|
for (let i = 0; i < staleClientIds.length; i += 1000) {
|
||||||
|
const staleChunk = staleClientIds.slice(i, i + 1000);
|
||||||
|
if (staleChunk.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.publisher.sRem(setKey, staleChunk);
|
||||||
|
if (setKey !== CLIENT_PRESENCE_ALL_KEY) {
|
||||||
|
await this.publisher.sRem(CLIENT_PRESENCE_ALL_KEY, staleChunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return presences;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseClientPresence(value: unknown): ClientPresence | null {
|
private parseClientPresence(value: unknown): ClientPresence | null {
|
||||||
|
|||||||
Reference in New Issue
Block a user