mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 17:15:03 -07:00
Migrate pool storage to TimescaleDB and Redis
This commit is contained in:
@@ -1,21 +1,15 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { UserAgentReportService } from '../ORM/_views/user-agent-report/user-agent-report.service';
|
||||
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
|
||||
import { ClientService } from '../ORM/client/client.service';
|
||||
import { HomeGraphService } from '../ORM/home-graph/home-graph.service';
|
||||
import { RpcBlockService } from '../ORM/rpc-block/rpc-block.service';
|
||||
|
||||
@Injectable()
|
||||
export class AppService implements OnModuleInit {
|
||||
|
||||
constructor(
|
||||
private readonly clientStatisticsService: ClientStatisticsService,
|
||||
private readonly clientService: ClientService,
|
||||
private readonly rpcBlockService: RpcBlockService,
|
||||
private readonly homeGraphService: HomeGraphService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly userAgentReportService: UserAgentReportService
|
||||
) {
|
||||
|
||||
@@ -25,26 +19,14 @@ export class AppService implements OnModuleInit {
|
||||
if (process.env.MASTER == 'true') {
|
||||
|
||||
setInterval(async () => {
|
||||
await this.deleteOldStatistics();
|
||||
await this.deleteOldClients();
|
||||
}, 1000 * 60 * 60);
|
||||
|
||||
setInterval(async () => {
|
||||
console.log('Killing dead clients');
|
||||
while (await this.clientService.killDeadClients()) {
|
||||
|
||||
}
|
||||
console.log('Finished killing clients');
|
||||
}, 1000 * 60 * 5);
|
||||
|
||||
setInterval(async () => {
|
||||
console.log('Deleting Old Blocks');
|
||||
await this.rpcBlockService.deleteOldBlocks();
|
||||
}, 1000 * 60 * 60 * 24);
|
||||
|
||||
setInterval(async () => {
|
||||
await this.updateChart();
|
||||
}, 1000 * 60 * 10);
|
||||
|
||||
setInterval(async () => {
|
||||
console.log('Refreshing user agent report view')
|
||||
await this.userAgentReportService.refreshReport();
|
||||
@@ -52,61 +34,13 @@ export class AppService implements OnModuleInit {
|
||||
}, 1000 * 60 * 5);
|
||||
|
||||
}
|
||||
|
||||
setInterval(async () => {
|
||||
//console.log('Bulk update client stats');
|
||||
await this.clientStatisticsService.doBulkAsyncUpdate();
|
||||
}, 1000 * 30);
|
||||
|
||||
setInterval(async () => {
|
||||
//console.log('Bulk update client stats');
|
||||
await this.clientService.doBulkHeartbeatUpdate();
|
||||
}, 1000 * 30);
|
||||
|
||||
|
||||
}
|
||||
|
||||
private async deleteOldStatistics() {
|
||||
console.log('Deleting statistics');
|
||||
private async deleteOldClients() {
|
||||
console.log('Deleting old clients');
|
||||
|
||||
const deletedStatistics = await this.clientStatisticsService.deleteOldStatistics();
|
||||
console.log(`Deleted ${deletedStatistics.affected} old statistics`);
|
||||
const deletedClients = await this.clientService.deleteOldClients();
|
||||
console.log(`Deleted ${deletedClients.affected} old clients`);
|
||||
|
||||
}
|
||||
|
||||
|
||||
private async updateChart() {
|
||||
console.log('Updating Chart');
|
||||
|
||||
const latestGraphUpdate = await this.homeGraphService.getLatestTime();
|
||||
|
||||
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
time AS label,
|
||||
ROUND(((SUM(shares) * 4294967296) / 600)) AS data
|
||||
FROM
|
||||
client_statistics_entity AS entry
|
||||
WHERE
|
||||
entry.time > ${latestGraphUpdate.getTime()}
|
||||
GROUP BY
|
||||
time
|
||||
ORDER BY
|
||||
time
|
||||
LIMIT 144;
|
||||
`)
|
||||
|
||||
console.log(`Fetched ${data.length} rows`);
|
||||
|
||||
if (data.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = data.slice(0, data.length - 1);
|
||||
|
||||
this.homeGraphService.save(result);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as zmq from 'zeromq';
|
||||
|
||||
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
|
||||
import * as PGPubsub from 'pg-pubsub';
|
||||
import { RedisMessagingService } from './redis-messaging.service';
|
||||
|
||||
@Injectable()
|
||||
export class BitcoinRpcService implements OnModuleInit {
|
||||
@@ -15,7 +15,6 @@ export class BitcoinRpcService implements OnModuleInit {
|
||||
|
||||
private client: AxiosInstance;
|
||||
private _newBlockTemplate$: BehaviorSubject<IBlockTemplate> = new BehaviorSubject(undefined);
|
||||
private pubsubInstance: PGPubsub;
|
||||
private resetTemplateInterval$ = new Subject<void>();
|
||||
private rpcRequestId = 0;
|
||||
|
||||
@@ -24,16 +23,14 @@ export class BitcoinRpcService implements OnModuleInit {
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private rpcBlockService: RpcBlockService
|
||||
private rpcBlockService: RpcBlockService,
|
||||
private readonly redisMessagingService: RedisMessagingService
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
|
||||
this.pubsubInstance = new PGPubsub('postgres://' + this.configService.get('DB_USERNAME') + ':' + this.configService.get('DB_PASSWORD') + '@' + this.configService.get('DB_HOST') + ':' + this.configService.get('DB_PORT') + '/' + this.configService.get('DB_DATABASE'))
|
||||
|
||||
|
||||
const url = this.configService.get('BITCOIN_RPC_URL');
|
||||
const user = this.configService.get('BITCOIN_RPC_USER');
|
||||
const pass = this.configService.get('BITCOIN_RPC_PASSWORD');
|
||||
@@ -60,11 +57,10 @@ export class BitcoinRpcService implements OnModuleInit {
|
||||
|
||||
console.log(`MASTER? ${process.env.MASTER}`)
|
||||
if (process.env.MASTER != 'true') {
|
||||
this.pubsubInstance.addChannel('miningInfo', async (miningInfo: IMiningInfo) => {
|
||||
//console.log('PG Sub. new template');
|
||||
await this.loadLatestTemplateForWorker();
|
||||
await this.redisMessagingService.subscribeMiningInfoUpdates(async (miningInfo: IMiningInfo) => {
|
||||
this.miningInfo = miningInfo;
|
||||
const savedBlockTemplate = await this.rpcBlockService.getSavedBlockTemplate(miningInfo.blocks);
|
||||
this._newBlockTemplate$.next(JSON.parse(savedBlockTemplate.data));
|
||||
await this.loadTemplateForWorker(miningInfo.blocks);
|
||||
});
|
||||
} else {
|
||||
console.log('Using ZMQ');
|
||||
@@ -109,7 +105,36 @@ export class BitcoinRpcService implements OnModuleInit {
|
||||
public async getAndBroadcastLatestTemplate() {
|
||||
const blockTemplate = await this.loadBlockTemplate(this.miningInfo.blocks);
|
||||
this._newBlockTemplate$.next(blockTemplate);
|
||||
await this.pubsubInstance.publish('miningInfo', this.miningInfo);
|
||||
await this.redisMessagingService.setLatestMiningInfo(this.miningInfo);
|
||||
await this.redisMessagingService.setBlockTemplate(this.miningInfo.blocks, blockTemplate);
|
||||
await this.redisMessagingService.publishMiningInfoUpdate(this.miningInfo);
|
||||
}
|
||||
|
||||
private async loadLatestTemplateForWorker() {
|
||||
const latestMiningInfo = await this.redisMessagingService.getLatestMiningInfo();
|
||||
if (latestMiningInfo != null) {
|
||||
this.miningInfo = latestMiningInfo;
|
||||
await this.loadTemplateForWorker(latestMiningInfo.blocks);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestBlockTemplate = await this.redisMessagingService.getLatestBlockTemplate();
|
||||
if (latestBlockTemplate != null) {
|
||||
this._newBlockTemplate$.next(latestBlockTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadTemplateForWorker(blockHeight: number) {
|
||||
const redisBlockTemplate = await this.redisMessagingService.getBlockTemplate(blockHeight);
|
||||
if (redisBlockTemplate != null) {
|
||||
this._newBlockTemplate$.next(redisBlockTemplate);
|
||||
return;
|
||||
}
|
||||
|
||||
const savedBlockTemplate = await this.rpcBlockService.getSavedBlockTemplate(blockHeight);
|
||||
if (savedBlockTemplate?.data != null) {
|
||||
this._newBlockTemplate$.next(JSON.parse(savedBlockTemplate.data));
|
||||
}
|
||||
}
|
||||
|
||||
private async loadBlockTemplate(blockHeight: number) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { RedisMessagingService } from './redis-messaging.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [RedisMessagingService],
|
||||
exports: [RedisMessagingService],
|
||||
})
|
||||
export class RedisMessagingModule { }
|
||||
@@ -0,0 +1,182 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createClient } from 'redis';
|
||||
|
||||
import { RedisMessagingService } from './redis-messaging.service';
|
||||
|
||||
jest.mock('redis', () => ({
|
||||
createClient: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('RedisMessagingService', () => {
|
||||
let clients: any[];
|
||||
let service: RedisMessagingService;
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
sets.clear();
|
||||
clientsByRole.publisher = null;
|
||||
clientsByRole.subscriber = null;
|
||||
clients = [createRedisClient(), createRedisClient()];
|
||||
(createClient as jest.Mock).mockImplementation(() => clients.shift());
|
||||
service = new RedisMessagingService({
|
||||
get: jest.fn((key: string) => key === 'REDIS_URL' ? 'redis://test-redis:6379' : null),
|
||||
} as unknown as ConfigService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should store and replay latest mining info and block templates', async () => {
|
||||
await service.connect();
|
||||
|
||||
await service.setLatestMiningInfo({ blocks: 900000 } as any);
|
||||
await service.setBlockTemplate(900000, { height: 900000, transactions: [] } as any);
|
||||
|
||||
expect(await service.getLatestMiningInfo()).toEqual({ blocks: 900000 });
|
||||
expect(await service.getBlockTemplate(900000)).toEqual({ height: 900000, transactions: [] });
|
||||
expect(await service.getLatestBlockTemplate()).toEqual({ height: 900000, transactions: [] });
|
||||
});
|
||||
|
||||
it('should publish and subscribe to mining info updates', async () => {
|
||||
await service.connect();
|
||||
const handler = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await service.subscribeMiningInfoUpdates(handler);
|
||||
await service.publishMiningInfoUpdate({ blocks: 900001 } as any);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ blocks: 900001 });
|
||||
});
|
||||
|
||||
it('should ignore malformed pubsub messages', async () => {
|
||||
await service.connect();
|
||||
const handler = jest.fn();
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await service.subscribeMiningInfoUpdates(handler);
|
||||
await clientsByRole.subscriber.callback('{bad json');
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid Redis mining info update'));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should store, index, and remove client presence', async () => {
|
||||
await service.connect();
|
||||
|
||||
await service.setClientPresence({
|
||||
clientId: '00000000-0000-4000-8000-000000000001',
|
||||
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||
clientName: 'worker',
|
||||
sessionId: '57a6f098',
|
||||
userAgent: 'bitaxe',
|
||||
startTime: '2026-06-07T12:00:00.000Z',
|
||||
lastSeen: '2026-06-07T12:01:00.000Z',
|
||||
hashRate: 100,
|
||||
bestDifficulty: 200,
|
||||
});
|
||||
|
||||
expect(await service.getClientPresence('00000000-0000-4000-8000-000000000001'))
|
||||
.toEqual(expect.objectContaining({
|
||||
clientId: '00000000-0000-4000-8000-000000000001',
|
||||
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||
hashRate: 100,
|
||||
bestDifficulty: 200,
|
||||
}));
|
||||
expect(await service.getClientPresenceByAddress('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'))
|
||||
.toHaveLength(1);
|
||||
expect(await service.getAllClientPresence()).toHaveLength(1);
|
||||
|
||||
await service.removeClientPresence('00000000-0000-4000-8000-000000000001');
|
||||
|
||||
expect(await service.getAllClientPresence()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should clear all client presence keys', async () => {
|
||||
await service.connect();
|
||||
|
||||
await service.setClientPresence({
|
||||
clientId: '00000000-0000-4000-8000-000000000001',
|
||||
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||
clientName: 'worker',
|
||||
sessionId: '57a6f098',
|
||||
userAgent: 'bitaxe',
|
||||
startTime: '2026-06-07T12:00:00.000Z',
|
||||
lastSeen: '2026-06-07T12:01:00.000Z',
|
||||
hashRate: 100,
|
||||
bestDifficulty: 200,
|
||||
});
|
||||
|
||||
await service.clearClientPresence();
|
||||
|
||||
expect(await service.getAllClientPresence()).toHaveLength(0);
|
||||
expect([...store.keys()].filter(key => key.startsWith('client-presence'))).toHaveLength(0);
|
||||
expect([...sets.keys()].filter(key => key.startsWith('client-presence'))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
const store = new Map<string, string>();
|
||||
const sets = new Map<string, Set<string>>();
|
||||
const clientsByRole: { publisher?: any; subscriber?: any } = {};
|
||||
|
||||
function createRedisClient() {
|
||||
const client = {
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
quit: jest.fn().mockResolvedValue(undefined),
|
||||
on: jest.fn(),
|
||||
set: jest.fn((key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
return Promise.resolve('OK');
|
||||
}),
|
||||
setEx: jest.fn((key: string, seconds: number, value: string) => {
|
||||
store.set(key, value);
|
||||
return Promise.resolve('OK');
|
||||
}),
|
||||
get: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)),
|
||||
del: jest.fn((...args: (string | string[])[]) => {
|
||||
const keys = args.flatMap(key => Array.isArray(key) ? key : [key]);
|
||||
let deleted = 0;
|
||||
keys.forEach(item => {
|
||||
deleted += store.delete(item) ? 1 : 0;
|
||||
deleted += sets.delete(item) ? 1 : 0;
|
||||
});
|
||||
return Promise.resolve(deleted);
|
||||
}),
|
||||
sAdd: jest.fn((key: string, value: string) => {
|
||||
const set = sets.get(key) ?? new Set<string>();
|
||||
set.add(value);
|
||||
sets.set(key, set);
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
sRem: jest.fn((key: string, value: string) => {
|
||||
const set = sets.get(key);
|
||||
const deleted = set?.delete(value) ? 1 : 0;
|
||||
return Promise.resolve(deleted);
|
||||
}),
|
||||
sMembers: jest.fn((key: string) => Promise.resolve([...sets.get(key) ?? []])),
|
||||
scanIterator: jest.fn(async function* scanIterator({ MATCH }: { MATCH: string }) {
|
||||
const prefix = MATCH.replace('*', '');
|
||||
for (const key of [...store.keys(), ...sets.keys()]) {
|
||||
if (key.startsWith(prefix)) {
|
||||
yield key;
|
||||
}
|
||||
}
|
||||
}),
|
||||
publish: jest.fn((channel: string, value: string) => {
|
||||
void clientsByRole.subscriber?.callback(value);
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
subscribe: jest.fn((channel: string, callback: (message: string) => Promise<void>) => {
|
||||
clientsByRole.subscriber.callback = callback;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
|
||||
if (clientsByRole.publisher == null) {
|
||||
clientsByRole.publisher = client;
|
||||
} else {
|
||||
clientsByRole.subscriber = client;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
|
||||
|
||||
const MINING_INFO_CHANNEL = 'mining-info.updated';
|
||||
const MINING_INFO_KEY = 'mining-info:latest';
|
||||
const BLOCK_TEMPLATE_LATEST_KEY = 'block-template:latest';
|
||||
const CLIENT_PRESENCE_TTL_SECONDS = 180;
|
||||
const blockTemplateKey = (height: number) => `block-template:${height}`;
|
||||
const CLIENT_PRESENCE_ALL_KEY = 'client-presence:all';
|
||||
const clientPresenceKey = (clientId: string) => `client-presence:${clientId}`;
|
||||
const clientPresenceAddressKey = (address: string) => `client-presence:address:${address}`;
|
||||
|
||||
export interface ClientPresence {
|
||||
clientId: string;
|
||||
address: string;
|
||||
clientName: string;
|
||||
sessionId: string;
|
||||
userAgent?: string | null;
|
||||
startTime: string;
|
||||
lastSeen: string;
|
||||
hashRate: number;
|
||||
bestDifficulty: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||
private publisher: RedisClientType;
|
||||
private subscriber: RedisClientType;
|
||||
private connected = false;
|
||||
private readonly clientPresenceTtlSeconds: number;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.clientPresenceTtlSeconds = this.readPositiveInt('CLIENT_PRESENCE_TTL_SECONDS', CLIENT_PRESENCE_TTL_SECONDS);
|
||||
}
|
||||
|
||||
public async onModuleInit() {
|
||||
await this.connect().catch(error => {
|
||||
console.error(`Redis unavailable at startup: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
public async onModuleDestroy() {
|
||||
await Promise.all([
|
||||
this.publisher?.quit().catch(() => undefined),
|
||||
this.subscriber?.quit().catch(() => undefined),
|
||||
]);
|
||||
}
|
||||
|
||||
public async connect() {
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = this.configService.get<string>('REDIS_URL') ?? process.env.REDIS_URL ?? 'redis://localhost:6379';
|
||||
const socket = {
|
||||
reconnectStrategy: (retries: number) => Math.min(retries * 250, 5000),
|
||||
};
|
||||
this.publisher = createClient({ url, socket });
|
||||
this.subscriber = createClient({ url, socket });
|
||||
|
||||
this.publisher.on('error', error => console.error(`Redis publisher error: ${error.message}`));
|
||||
this.subscriber.on('error', error => console.error(`Redis subscriber error: ${error.message}`));
|
||||
this.publisher.on('end', () => { this.connected = false; });
|
||||
this.subscriber.on('end', () => { this.connected = false; });
|
||||
|
||||
await Promise.all([this.publisher.connect(), this.subscriber.connect()]);
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
public async publishMiningInfoUpdate(miningInfo: IMiningInfo) {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
await this.publisher.publish(MINING_INFO_CHANNEL, JSON.stringify(miningInfo));
|
||||
}
|
||||
|
||||
public async subscribeMiningInfoUpdates(handler: (miningInfo: IMiningInfo) => Promise<void>) {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
await this.subscriber.subscribe(MINING_INFO_CHANNEL, async message => {
|
||||
try {
|
||||
await handler(JSON.parse(message));
|
||||
} catch (error) {
|
||||
console.error(`Invalid Redis mining info update: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async setLatestMiningInfo(miningInfo: IMiningInfo) {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
await this.publisher.set(MINING_INFO_KEY, JSON.stringify(miningInfo));
|
||||
}
|
||||
|
||||
public async getLatestMiningInfo(): Promise<IMiningInfo | null> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return null;
|
||||
}
|
||||
const value = await this.publisher.get(MINING_INFO_KEY);
|
||||
return value == null ? null : JSON.parse(value as string);
|
||||
}
|
||||
|
||||
public async setBlockTemplate(height: number, blockTemplate: IBlockTemplate) {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
const serialized = JSON.stringify(blockTemplate);
|
||||
await Promise.all([
|
||||
this.publisher.set(blockTemplateKey(height), serialized),
|
||||
this.publisher.set(BLOCK_TEMPLATE_LATEST_KEY, serialized),
|
||||
]);
|
||||
}
|
||||
|
||||
public async getBlockTemplate(height: number): Promise<IBlockTemplate | null> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return null;
|
||||
}
|
||||
const value = await this.publisher.get(blockTemplateKey(height));
|
||||
return value == null ? null : JSON.parse(value as string);
|
||||
}
|
||||
|
||||
public async getLatestBlockTemplate(): Promise<IBlockTemplate | null> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return null;
|
||||
}
|
||||
const value = await this.publisher.get(BLOCK_TEMPLATE_LATEST_KEY);
|
||||
return value == null ? null : JSON.parse(value as string);
|
||||
}
|
||||
|
||||
public async setClientPresence(presence: ClientPresence): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify({
|
||||
...presence,
|
||||
userAgent: presence.userAgent ?? null,
|
||||
hashRate: Number.isFinite(Number(presence.hashRate)) ? Number(presence.hashRate) : 0,
|
||||
bestDifficulty: Number.isFinite(Number(presence.bestDifficulty)) ? Number(presence.bestDifficulty) : 0,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.publisher.setEx(clientPresenceKey(presence.clientId), this.clientPresenceTtlSeconds, serialized),
|
||||
this.publisher.sAdd(CLIENT_PRESENCE_ALL_KEY, presence.clientId),
|
||||
this.publisher.sAdd(clientPresenceAddressKey(presence.address), presence.clientId),
|
||||
]);
|
||||
}
|
||||
|
||||
public async removeClientPresence(clientId: string, address?: string): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedAddress = address;
|
||||
if (resolvedAddress == null) {
|
||||
const presence = await this.getClientPresence(clientId);
|
||||
resolvedAddress = presence?.address;
|
||||
}
|
||||
|
||||
const removals: Promise<unknown>[] = [
|
||||
this.publisher.del(clientPresenceKey(clientId)),
|
||||
this.publisher.sRem(CLIENT_PRESENCE_ALL_KEY, clientId),
|
||||
];
|
||||
if (resolvedAddress != null) {
|
||||
removals.push(this.publisher.sRem(clientPresenceAddressKey(resolvedAddress), clientId));
|
||||
}
|
||||
|
||||
await Promise.all(removals);
|
||||
}
|
||||
|
||||
public async getClientPresence(clientId: string): Promise<ClientPresence | null> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = await this.publisher.get(clientPresenceKey(clientId));
|
||||
return this.parseClientPresence(value);
|
||||
}
|
||||
|
||||
public async getClientPresenceByAddress(address: string): Promise<ClientPresence[]> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.getPresenceFromSet(clientPresenceAddressKey(address));
|
||||
}
|
||||
|
||||
public async getAllClientPresence(): Promise<ClientPresence[]> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.getPresenceFromSet(CLIENT_PRESENCE_ALL_KEY);
|
||||
}
|
||||
|
||||
public async clearClientPresence(): Promise<void> {
|
||||
if (!await this.ensureConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch: string[] = [];
|
||||
for await (const keyOrKeys of (this.publisher as any).scanIterator({ MATCH: 'client-presence*', COUNT: 1000 })) {
|
||||
const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys];
|
||||
batch.push(...keys.map(key => key as string));
|
||||
if (batch.length >= 500) {
|
||||
await this.deleteKeys(batch.splice(0));
|
||||
}
|
||||
}
|
||||
|
||||
await this.deleteKeys(batch);
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<boolean> {
|
||||
if (!this.connected) {
|
||||
try {
|
||||
await this.connect();
|
||||
} catch (error) {
|
||||
console.error(`Redis messaging degraded: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getPresenceFromSet(setKey: string): Promise<ClientPresence[]> {
|
||||
const clientIds = await this.publisher.sMembers(setKey);
|
||||
if (clientIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const presences = await Promise.all(clientIds.map(async clientId => {
|
||||
const presence = await this.getClientPresence(clientId);
|
||||
if (presence == null) {
|
||||
await this.publisher.sRem(setKey, clientId);
|
||||
if (setKey !== CLIENT_PRESENCE_ALL_KEY) {
|
||||
await this.publisher.sRem(CLIENT_PRESENCE_ALL_KEY, clientId);
|
||||
}
|
||||
}
|
||||
return presence;
|
||||
}));
|
||||
|
||||
return presences.filter((presence): presence is ClientPresence => presence != null);
|
||||
}
|
||||
|
||||
private parseClientPresence(value: unknown): ClientPresence | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value as string);
|
||||
if (parsed?.clientId == null || parsed?.address == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
clientId: parsed.clientId,
|
||||
address: parsed.address,
|
||||
clientName: parsed.clientName ?? 'default',
|
||||
sessionId: parsed.sessionId ?? parsed.clientId,
|
||||
userAgent: parsed.userAgent ?? null,
|
||||
startTime: parsed.startTime,
|
||||
lastSeen: parsed.lastSeen,
|
||||
hashRate: Number(parsed.hashRate ?? 0),
|
||||
bestDifficulty: Number(parsed.bestDifficulty ?? 0),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Invalid Redis client presence: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private readPositiveInt(name: string, defaultValue: number): number {
|
||||
const value = Number(this.configService.get<string>(name) ?? process.env[name]);
|
||||
return Number.isInteger(value) && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private async deleteKeys(keys: string[]): Promise<void> {
|
||||
if (keys.length === 0) {
|
||||
return;
|
||||
}
|
||||
await (this.publisher as any).del(...keys);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ describe('StratumV1Service', () => {
|
||||
|
||||
let service: StratumV1Service;
|
||||
let clientService;
|
||||
let userAgentReportService;
|
||||
let stratumV2Service;
|
||||
let redisMessagingService;
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
|
||||
@@ -20,10 +22,16 @@ describe('StratumV1Service', () => {
|
||||
clientService = {
|
||||
deleteAll: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
userAgentReportService = {
|
||||
refreshReport: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
stratumV2Service = {
|
||||
ensureInitialized: jest.fn().mockResolvedValue(undefined),
|
||||
createClient: jest.fn()
|
||||
};
|
||||
redisMessagingService = {
|
||||
clearClientPresence: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
service = new StratumV1Service(
|
||||
{} as any,
|
||||
clientService,
|
||||
@@ -32,8 +40,10 @@ describe('StratumV1Service', () => {
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
stratumV2Service as any
|
||||
stratumV2Service as any,
|
||||
userAgentReportService as any,
|
||||
undefined,
|
||||
redisMessagingService as any
|
||||
);
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
@@ -61,6 +71,8 @@ describe('StratumV1Service', () => {
|
||||
jest.runOnlyPendingTimers();
|
||||
|
||||
expect(clientService.deleteAll).toHaveBeenCalled();
|
||||
expect(redisMessagingService.clearClientPresence).toHaveBeenCalled();
|
||||
expect(userAgentReportService.refreshReport).toHaveBeenCalled();
|
||||
expect(startSocketServerSpy).not.toHaveBeenCalled();
|
||||
expect(startSecureSocketServerSpy).not.toHaveBeenCalled();
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('Master process skipping Stratum socket listeners');
|
||||
@@ -78,6 +90,7 @@ describe('StratumV1Service', () => {
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
expect(clientService.deleteAll).not.toHaveBeenCalled();
|
||||
expect(userAgentReportService.refreshReport).not.toHaveBeenCalled();
|
||||
expect(startSocketServerSpy).toHaveBeenCalledWith(3333);
|
||||
expect(startSocketServerSpy).toHaveBeenCalledWith(3334);
|
||||
expect(startSecureSocketServerSpy).toHaveBeenCalledWith(4333);
|
||||
|
||||
@@ -5,12 +5,14 @@ import { monitorEventLoopDelay } from 'perf_hooks';
|
||||
|
||||
import { StratumV1Client } from '../models/StratumV1Client';
|
||||
import { StratumV2Client } from '../models/StratumV2Client';
|
||||
import { UserAgentReportService } from '../ORM/_views/user-agent-report/user-agent-report.service';
|
||||
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
|
||||
import { BlocksService } from '../ORM/blocks/blocks.service';
|
||||
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
|
||||
import { ClientService } from '../ORM/client/client.service';
|
||||
import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service';
|
||||
import { BitcoinRpcService } from './bitcoin-rpc.service';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { RedisMessagingService } from './redis-messaging.service';
|
||||
import { StratumV1JobsService } from './stratum-v1-jobs.service';
|
||||
import { StratumV2Service } from './stratum-v2.service';
|
||||
|
||||
@@ -51,13 +53,15 @@ export class StratumV1Service implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly bitcoinRpcService: BitcoinRpcService,
|
||||
private readonly clientService: ClientService,
|
||||
private readonly clientStatisticsService: ClientStatisticsService,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly blocksService: BlocksService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly stratumV1JobsService: StratumV1JobsService,
|
||||
private readonly addressSettingsService: AddressSettingsService,
|
||||
private readonly stratumV2Service: StratumV2Service
|
||||
private readonly stratumV2Service: StratumV2Service,
|
||||
private readonly userAgentReportService: UserAgentReportService,
|
||||
private readonly shareAccountingService?: ShareAccountingService,
|
||||
private readonly redisMessagingService?: RedisMessagingService
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -66,6 +70,8 @@ export class StratumV1Service implements OnModuleInit {
|
||||
|
||||
if (process.env.MASTER == 'true') {
|
||||
await this.clientService.deleteAll();
|
||||
await this.redisMessagingService?.clearClientPresence();
|
||||
await this.userAgentReportService.refreshReport();
|
||||
console.log('Master process skipping Stratum socket listeners');
|
||||
return;
|
||||
}
|
||||
@@ -191,11 +197,12 @@ export class StratumV1Service implements OnModuleInit {
|
||||
this.stratumV1JobsService,
|
||||
this.bitcoinRpcService,
|
||||
this.clientService,
|
||||
this.clientStatisticsService,
|
||||
this.notificationService,
|
||||
this.blocksService,
|
||||
this.configService,
|
||||
this.addressSettingsService
|
||||
this.addressSettingsService,
|
||||
this.shareAccountingService,
|
||||
this.redisMessagingService
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Server, Socket } from 'net';
|
||||
|
||||
import { AddressSettingsService } from '../ORM/address-settings/address-settings.service';
|
||||
import { BlocksService } from '../ORM/blocks/blocks.service';
|
||||
import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service';
|
||||
import { ClientService } from '../ORM/client/client.service';
|
||||
import { ShareAccountingService } from '../ORM/share-accounting/share-accounting.service';
|
||||
import { StratumV2Client } from '../models/StratumV2Client';
|
||||
import { encodeSv2AuthorityPublicKey } from '../models/sv2/sv2-authority-key';
|
||||
import { Sv2ExtranonceManager } from '../models/sv2/sv2-extranonce-manager';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../models/sv2/sv2-noise';
|
||||
import { BitcoinRpcService } from './bitcoin-rpc.service';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { RedisMessagingService } from './redis-messaging.service';
|
||||
import { StratumV1JobsService } from './stratum-v1-jobs.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -35,12 +36,13 @@ export class StratumV2Service implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly bitcoinRpcService: BitcoinRpcService,
|
||||
private readonly clientService: ClientService,
|
||||
private readonly clientStatisticsService: ClientStatisticsService,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly blocksService: BlocksService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly stratumV1JobsService: StratumV1JobsService,
|
||||
private readonly addressSettingsService: AddressSettingsService,
|
||||
private readonly shareAccountingService?: ShareAccountingService,
|
||||
private readonly redisMessagingService?: RedisMessagingService,
|
||||
) {}
|
||||
|
||||
public async onModuleInit(): Promise<void> {
|
||||
@@ -77,11 +79,12 @@ export class StratumV2Service implements OnModuleInit {
|
||||
this.stratumV1JobsService,
|
||||
this.bitcoinRpcService,
|
||||
this.clientService,
|
||||
this.clientStatisticsService,
|
||||
this.notificationService,
|
||||
this.blocksService,
|
||||
this.configService,
|
||||
this.addressSettingsService,
|
||||
this.shareAccountingService,
|
||||
this.redisMessagingService,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user