mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 09:05:06 -07:00
Remove Redis client presence
This commit is contained in:
@@ -99,7 +99,6 @@ SHARE_ACCOUNTING_FLUSH_INTERVAL_MS=25
|
|||||||
SHARE_ACCOUNTING_MAX_QUEUE_SIZE=50000
|
SHARE_ACCOUNTING_MAX_QUEUE_SIZE=50000
|
||||||
SHARE_ACCOUNTING_SUMMARY_CACHE_MS=2500
|
SHARE_ACCOUNTING_SUMMARY_CACHE_MS=2500
|
||||||
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX=10000
|
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX=10000
|
||||||
CLIENT_PRESENCE_ENABLED=true
|
|
||||||
CLIENT_HASHRATE_PERSIST_INTERVAL_MS=60000
|
CLIENT_HASHRATE_PERSIST_INTERVAL_MS=60000
|
||||||
SHARE_ROLLUP_ENABLED=true
|
SHARE_ROLLUP_ENABLED=true
|
||||||
SHARE_ROLLUP_INTERVAL_MS=60000
|
SHARE_ROLLUP_INTERVAL_MS=60000
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ services:
|
|||||||
SHARE_ACCOUNTING_MAX_QUEUE_SIZE: ${SHARE_ACCOUNTING_MAX_QUEUE_SIZE:-50000}
|
SHARE_ACCOUNTING_MAX_QUEUE_SIZE: ${SHARE_ACCOUNTING_MAX_QUEUE_SIZE:-50000}
|
||||||
SHARE_ACCOUNTING_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MS:-2500}
|
SHARE_ACCOUNTING_SUMMARY_CACHE_MS: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MS:-2500}
|
||||||
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MAX:-10000}
|
SHARE_ACCOUNTING_SUMMARY_CACHE_MAX: ${SHARE_ACCOUNTING_SUMMARY_CACHE_MAX:-10000}
|
||||||
CLIENT_PRESENCE_ENABLED: ${CLIENT_PRESENCE_ENABLED:-true}
|
|
||||||
SHARE_ROLLUP_ENABLED: ${SHARE_ROLLUP_ENABLED:-true}
|
SHARE_ROLLUP_ENABLED: ${SHARE_ROLLUP_ENABLED:-true}
|
||||||
SHARE_ROLLUP_INTERVAL_MS: ${SHARE_ROLLUP_INTERVAL_MS:-60000}
|
SHARE_ROLLUP_INTERVAL_MS: ${SHARE_ROLLUP_INTERVAL_MS:-60000}
|
||||||
SHARE_ROLLUP_SAFETY_LAG_SECONDS: ${SHARE_ROLLUP_SAFETY_LAG_SECONDS:-30}
|
SHARE_ROLLUP_SAFETY_LAG_SECONDS: ${SHARE_ROLLUP_SAFETY_LAG_SECONDS:-30}
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ services:
|
|||||||
PPLNS_SV2_TDP_PORTS: ${PPLNS_SV2_TDP_PORTS:-}
|
PPLNS_SV2_TDP_PORTS: ${PPLNS_SV2_TDP_PORTS:-}
|
||||||
DATUM_PORTS: ${DATUM_PORTS:-}
|
DATUM_PORTS: ${DATUM_PORTS:-}
|
||||||
PPLNS_DATUM_PORTS: ${PPLNS_DATUM_PORTS:-}
|
PPLNS_DATUM_PORTS: ${PPLNS_DATUM_PORTS:-}
|
||||||
CLIENT_PRESENCE_ENABLED: ${CLIENT_PRESENCE_ENABLED:-true}
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "node -e \"const http=require('http'); const https=require('https'); const secure=process.env.API_SECURE==='true'; const client=secure?https:http; const req=client.get({hostname:'127.0.0.1',port:process.env.API_PORT||3334,path:'/api/network',rejectUnauthorized:false},res=>process.exit(res.statusCode<500?0:1)); req.on('error',()=>process.exit(1)); req.setTimeout(5000,()=>{req.destroy(); process.exit(1);});\""]
|
test: ["CMD-SHELL", "node -e \"const http=require('http'); const https=require('https'); const secure=process.env.API_SECURE==='true'; const client=secure?https:http; const req=client.get({hostname:'127.0.0.1',port:process.env.API_PORT||3334,path:'/api/network',rejectUnauthorized:false},res=>process.exit(res.statusCode<500?0:1)); req.on('error',()=>process.exit(1)); req.setTimeout(5000,()=>{req.destroy(); process.exit(1);});\""]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ services:
|
|||||||
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
|
STRATUM_MIN_DIFFICULTY: ${STRATUM_MIN_DIFFICULTY:-1}
|
||||||
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
|
STRATUM_SOCKET_TIMEOUT_MS: ${STRATUM_SOCKET_TIMEOUT_MS:-3600000}
|
||||||
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
|
STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS: ${STRATUM_TCP_KEEPALIVE_INITIAL_DELAY_MS:-60000}
|
||||||
CLIENT_PRESENCE_ENABLED: ${CLIENT_PRESENCE_ENABLED:-true}
|
|
||||||
SHARE_ROLLUP_ENABLED: ${SHARE_ROLLUP_ENABLED:-true}
|
SHARE_ROLLUP_ENABLED: ${SHARE_ROLLUP_ENABLED:-true}
|
||||||
SHARE_ROLLUP_INTERVAL_MS: ${SHARE_ROLLUP_INTERVAL_MS:-60000}
|
SHARE_ROLLUP_INTERVAL_MS: ${SHARE_ROLLUP_INTERVAL_MS:-60000}
|
||||||
SHARE_ROLLUP_SAFETY_LAG_SECONDS: ${SHARE_ROLLUP_SAFETY_LAG_SECONDS:-30}
|
SHARE_ROLLUP_SAFETY_LAG_SECONDS: ${SHARE_ROLLUP_SAFETY_LAG_SECONDS:-30}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('UserAgentReportService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses Redis live presence in API-only processes when workers are connected', async () => {
|
it('uses active database clients for live API-only reports', async () => {
|
||||||
process.env.API_ONLY = 'true';
|
process.env.API_ONLY = 'true';
|
||||||
const userAgentReport = {
|
const userAgentReport = {
|
||||||
find: jest.fn().mockResolvedValue([
|
find: jest.fn().mockResolvedValue([
|
||||||
@@ -23,26 +23,17 @@ describe('UserAgentReportService', () => {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
const clientRepository = {
|
const clientRepository = createClientRepository([
|
||||||
find: jest.fn().mockResolvedValue([{ id: 'client-1' }]),
|
{
|
||||||
};
|
userAgent: 'unknown/sv2',
|
||||||
|
count: '1',
|
||||||
|
bestDifficulty: 456,
|
||||||
|
totalHashRate: '123',
|
||||||
|
},
|
||||||
|
]);
|
||||||
const redisMessagingService = {
|
const redisMessagingService = {
|
||||||
getJsonCache: jest.fn().mockResolvedValue(null),
|
getJsonCache: jest.fn().mockResolvedValue(null),
|
||||||
getAllClientPresence: jest.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
clientId: 'client-1',
|
|
||||||
address: 'tb1qworker',
|
|
||||||
clientName: 'sv2gateway',
|
|
||||||
sessionId: 'session',
|
|
||||||
userAgent: 'unknown/sv2',
|
|
||||||
startTime: '2026-01-01T00:00:00.000Z',
|
|
||||||
lastSeen: '2026-01-01T00:00:10.000Z',
|
|
||||||
hashRate: 123,
|
|
||||||
bestDifficulty: 456,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
setJsonCache: jest.fn().mockResolvedValue(undefined),
|
setJsonCache: jest.fn().mockResolvedValue(undefined),
|
||||||
removeClientPresence: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
};
|
||||||
const service = new UserAgentReportService(
|
const service = new UserAgentReportService(
|
||||||
userAgentReport as any,
|
userAgentReport as any,
|
||||||
@@ -59,9 +50,10 @@ describe('UserAgentReportService', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(userAgentReport.find).not.toHaveBeenCalled();
|
expect(userAgentReport.find).not.toHaveBeenCalled();
|
||||||
|
expect(clientRepository.createQueryBuilder).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the materialized view in API-only processes when live presence is empty', async () => {
|
it('falls back to the materialized view when no active database clients exist', async () => {
|
||||||
process.env.API_ONLY = 'true';
|
process.env.API_ONLY = 'true';
|
||||||
const viewRows = [
|
const viewRows = [
|
||||||
{
|
{
|
||||||
@@ -74,14 +66,10 @@ describe('UserAgentReportService', () => {
|
|||||||
const userAgentReport = {
|
const userAgentReport = {
|
||||||
find: jest.fn().mockResolvedValue(viewRows),
|
find: jest.fn().mockResolvedValue(viewRows),
|
||||||
};
|
};
|
||||||
const clientRepository = {
|
const clientRepository = createClientRepository([]);
|
||||||
find: jest.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
const redisMessagingService = {
|
const redisMessagingService = {
|
||||||
getJsonCache: jest.fn().mockResolvedValue([]),
|
getJsonCache: jest.fn().mockResolvedValue([]),
|
||||||
getAllClientPresence: jest.fn().mockResolvedValue([]),
|
|
||||||
setJsonCache: jest.fn().mockResolvedValue(undefined),
|
setJsonCache: jest.fn().mockResolvedValue(undefined),
|
||||||
removeClientPresence: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
};
|
||||||
const service = new UserAgentReportService(
|
const service = new UserAgentReportService(
|
||||||
userAgentReport as any,
|
userAgentReport as any,
|
||||||
@@ -92,42 +80,23 @@ describe('UserAgentReportService', () => {
|
|||||||
await expect(service.getReport()).resolves.toEqual(viewRows);
|
await expect(service.getReport()).resolves.toEqual(viewRows);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters and removes stale Redis presence rows with no active database client', async () => {
|
it('returns a non-empty cached live report before querying the database', async () => {
|
||||||
process.env.API_ONLY = 'true';
|
process.env.API_ONLY = 'true';
|
||||||
|
const cachedRows = [
|
||||||
|
{
|
||||||
|
userAgent: 'cached',
|
||||||
|
count: '2',
|
||||||
|
bestDifficulty: 8,
|
||||||
|
totalHashRate: '16',
|
||||||
|
},
|
||||||
|
];
|
||||||
const userAgentReport = {
|
const userAgentReport = {
|
||||||
find: jest.fn().mockResolvedValue([]),
|
find: jest.fn(),
|
||||||
};
|
|
||||||
const clientRepository = {
|
|
||||||
find: jest.fn().mockResolvedValue([{ id: 'active-client' }]),
|
|
||||||
};
|
};
|
||||||
|
const clientRepository = createClientRepository([]);
|
||||||
const redisMessagingService = {
|
const redisMessagingService = {
|
||||||
getJsonCache: jest.fn().mockResolvedValue(null),
|
getJsonCache: jest.fn().mockResolvedValue(cachedRows),
|
||||||
getAllClientPresence: jest.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
clientId: 'active-client',
|
|
||||||
address: 'tb1qactive',
|
|
||||||
clientName: 'worker',
|
|
||||||
sessionId: 'active',
|
|
||||||
userAgent: 'bitaxe',
|
|
||||||
startTime: '2026-01-01T00:00:00.000Z',
|
|
||||||
lastSeen: '2026-01-01T00:00:10.000Z',
|
|
||||||
hashRate: 100,
|
|
||||||
bestDifficulty: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
clientId: 'stale-client',
|
|
||||||
address: 'tb1qstale',
|
|
||||||
clientName: 'worker',
|
|
||||||
sessionId: 'stale',
|
|
||||||
userAgent: 'bitaxe',
|
|
||||||
startTime: '2026-01-01T00:00:00.000Z',
|
|
||||||
lastSeen: '2026-01-01T00:00:10.000Z',
|
|
||||||
hashRate: 300,
|
|
||||||
bestDifficulty: 400,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
setJsonCache: jest.fn().mockResolvedValue(undefined),
|
setJsonCache: jest.fn().mockResolvedValue(undefined),
|
||||||
removeClientPresence: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
};
|
||||||
const service = new UserAgentReportService(
|
const service = new UserAgentReportService(
|
||||||
userAgentReport as any,
|
userAgentReport as any,
|
||||||
@@ -135,15 +104,22 @@ describe('UserAgentReportService', () => {
|
|||||||
redisMessagingService as any,
|
redisMessagingService as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(service.getReport()).resolves.toEqual([
|
await expect(service.getReport()).resolves.toEqual(cachedRows);
|
||||||
{
|
expect(clientRepository.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
userAgent: 'bitaxe',
|
|
||||||
count: '1',
|
|
||||||
bestDifficulty: 200,
|
|
||||||
totalHashRate: '100',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(redisMessagingService.removeClientPresence)
|
|
||||||
.toHaveBeenCalledWith('stale-client', 'tb1qstale');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createClientRepository(rows: unknown[]) {
|
||||||
|
const queryBuilder = {
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
groupBy: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
getRawMany: jest.fn().mockResolvedValue(rows),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createQueryBuilder: jest.fn().mockReturnValue(queryBuilder),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { In, Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import { ClientEntity } from '../../client/client.entity';
|
import { ClientEntity } from '../../client/client.entity';
|
||||||
import { UserAgentReportView } from './user-agent-report.view';
|
import { UserAgentReportView } from './user-agent-report.view';
|
||||||
@@ -60,76 +60,28 @@ export class UserAgentReportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async buildLiveReport() {
|
private async buildLiveReport() {
|
||||||
const presences = await this.redisMessagingService.getAllClientPresence();
|
const rows = await this.clientRepository
|
||||||
const activePresences = await this.filterActivePresences(presences);
|
.createQueryBuilder('client')
|
||||||
if (activePresences.length === 0) {
|
.select('COALESCE(NULLIF(client.userAgent, \'\'), \'Other\')', 'userAgent')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.addSelect('MAX(client.bestDifficulty)', 'bestDifficulty')
|
||||||
|
.addSelect('COALESCE(SUM(client.hashRate), 0)', 'totalHashRate')
|
||||||
|
.where('client.deletedAt IS NULL')
|
||||||
|
.groupBy('COALESCE(NULLIF(client.userAgent, \'\'), \'Other\')')
|
||||||
|
.orderBy('"totalHashRate"', 'DESC')
|
||||||
|
.getRawMany<UserAgentReportView>();
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
return await this.userAgentReport.find();
|
return await this.userAgentReport.find();
|
||||||
}
|
}
|
||||||
const rows = new Map<string, {
|
|
||||||
userAgent: string;
|
|
||||||
count: number;
|
|
||||||
bestDifficulty: number;
|
|
||||||
totalHashRate: number;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
activePresences.forEach(presence => {
|
|
||||||
const userAgent = presence.userAgent == null || presence.userAgent.length === 0
|
|
||||||
? 'Other'
|
|
||||||
: presence.userAgent;
|
|
||||||
const row = rows.get(userAgent) ?? {
|
|
||||||
userAgent,
|
|
||||||
count: 0,
|
|
||||||
bestDifficulty: 0,
|
|
||||||
totalHashRate: 0,
|
|
||||||
};
|
|
||||||
row.count++;
|
|
||||||
row.bestDifficulty = Math.max(
|
|
||||||
row.bestDifficulty,
|
|
||||||
Number(presence.bestDifficulty ?? 0),
|
|
||||||
);
|
|
||||||
row.totalHashRate += Number(presence.hashRate ?? 0);
|
|
||||||
rows.set(userAgent, row);
|
|
||||||
});
|
|
||||||
|
|
||||||
const report = [...rows.values()]
|
|
||||||
.sort((left, right) => right.totalHashRate - left.totalHashRate)
|
|
||||||
.map(row => ({
|
|
||||||
userAgent: row.userAgent,
|
|
||||||
count: row.count.toString(),
|
|
||||||
bestDifficulty: row.bestDifficulty,
|
|
||||||
totalHashRate: row.totalHashRate.toString(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
await this.redisMessagingService
|
await this.redisMessagingService
|
||||||
.setJsonCache(this.liveReportCacheKey, report, 60 * 1000)
|
.setJsonCache(this.liveReportCacheKey, rows, 60 * 1000)
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error(`Live user-agent report cache write failed: ${error.message}`);
|
console.error(`Live user-agent report cache write failed: ${error.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return report;
|
return rows;
|
||||||
}
|
|
||||||
|
|
||||||
private async filterActivePresences(presences: Awaited<ReturnType<RedisMessagingService['getAllClientPresence']>>) {
|
|
||||||
if (presences.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeClients = await this.clientRepository.find({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
id: In(presences.map(presence => presence.clientId)),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const activeIds = new Set(activeClients.map(client => client.id));
|
|
||||||
const stalePresences = presences.filter(presence => !activeIds.has(presence.clientId));
|
|
||||||
void Promise.all(stalePresences.map(presence => {
|
|
||||||
return this.redisMessagingService.removeClientPresence(presence.clientId, presence.address)
|
|
||||||
.catch(() => undefined);
|
|
||||||
}));
|
|
||||||
|
|
||||||
return presences.filter(presence => activeIds.has(presence.clientId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async refreshReport() {
|
public async refreshReport() {
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ export class ClientService {
|
|||||||
return await this.clientRepository.find({
|
return await this.clientRepository.find({
|
||||||
where: {
|
where: {
|
||||||
address
|
address
|
||||||
}
|
},
|
||||||
|
order: {
|
||||||
|
updatedAt: 'DESC',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import { ClientStatisticsService } from '../../ORM/client-statistics/client-stat
|
|||||||
import { ClientService } from '../../ORM/client/client.service';
|
import { ClientService } from '../../ORM/client/client.service';
|
||||||
import { PayoutSnapshotService } from '../../ORM/payout-snapshot/payout-snapshot.service';
|
import { PayoutSnapshotService } from '../../ORM/payout-snapshot/payout-snapshot.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';
|
|
||||||
import { ClientController } from './client.controller';
|
import { ClientController } from './client.controller';
|
||||||
|
|
||||||
describe('ClientController', () => {
|
describe('ClientController', () => {
|
||||||
let controller: ClientController;
|
let controller: ClientController;
|
||||||
let clientService: { getBySessionId: jest.Mock; getActiveIds: jest.Mock };
|
let clientService: { getByAddress: jest.Mock; getBySessionId: jest.Mock };
|
||||||
let addressSettingsService: { getSettings: jest.Mock };
|
let addressSettingsService: { getSettings: jest.Mock };
|
||||||
let shareAccountingService: {
|
let shareAccountingService: {
|
||||||
getAddressSummary: jest.Mock;
|
getAddressSummary: jest.Mock;
|
||||||
@@ -19,7 +18,6 @@ describe('ClientController', () => {
|
|||||||
getSessionSummaries: jest.Mock;
|
getSessionSummaries: jest.Mock;
|
||||||
};
|
};
|
||||||
let payoutSnapshotService: { getLatestExpectedPayoutForAddress: jest.Mock };
|
let payoutSnapshotService: { getLatestExpectedPayoutForAddress: jest.Mock };
|
||||||
let redisMessagingService: { getClientPresenceByAddress: jest.Mock; removeClientPresence: jest.Mock };
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -28,10 +26,9 @@ describe('ClientController', () => {
|
|||||||
{
|
{
|
||||||
provide: ClientService,
|
provide: ClientService,
|
||||||
useValue: {
|
useValue: {
|
||||||
getByAddress: jest.fn(),
|
getByAddress: jest.fn().mockResolvedValue([]),
|
||||||
getByName: jest.fn(),
|
getByName: jest.fn(),
|
||||||
getBySessionId: jest.fn(),
|
getBySessionId: jest.fn(),
|
||||||
getActiveIds: jest.fn(async (ids: string[]) => new Set(ids)),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,13 +60,6 @@ describe('ClientController', () => {
|
|||||||
getLatestExpectedPayoutForAddress: jest.fn().mockResolvedValue(null),
|
getLatestExpectedPayoutForAddress: jest.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: RedisMessagingService,
|
|
||||||
useValue: {
|
|
||||||
getClientPresenceByAddress: jest.fn().mockResolvedValue([]),
|
|
||||||
removeClientPresence: jest.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
|
||||||
}).compile();
|
}).compile();
|
||||||
@@ -79,7 +69,6 @@ describe('ClientController', () => {
|
|||||||
addressSettingsService = module.get(AddressSettingsService);
|
addressSettingsService = module.get(AddressSettingsService);
|
||||||
shareAccountingService = module.get(ShareAccountingService);
|
shareAccountingService = module.get(ShareAccountingService);
|
||||||
payoutSnapshotService = module.get(PayoutSnapshotService);
|
payoutSnapshotService = module.get(PayoutSnapshotService);
|
||||||
redisMessagingService = module.get(RedisMessagingService);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@@ -87,9 +76,9 @@ describe('ClientController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should expose the existing address best difficulty in accounting when rollup best is empty', async () => {
|
it('should expose the existing address best difficulty in accounting when rollup best is empty', async () => {
|
||||||
redisMessagingService.getClientPresenceByAddress.mockResolvedValue([
|
clientService.getByAddress.mockResolvedValue([
|
||||||
{
|
{
|
||||||
clientId: '92f5302f-5e32-487e-af67-f56fd78b13c7',
|
id: '92f5302f-5e32-487e-af67-f56fd78b13c7',
|
||||||
sessionId: 'abcd1234',
|
sessionId: 'abcd1234',
|
||||||
clientName: 'worker',
|
clientName: 'worker',
|
||||||
bestDifficulty: 64,
|
bestDifficulty: 64,
|
||||||
@@ -97,6 +86,7 @@ describe('ClientController', () => {
|
|||||||
startTime: '2026-06-08T12:00:00.000Z',
|
startTime: '2026-06-08T12:00:00.000Z',
|
||||||
lastSeen: '2026-06-08T12:10:00.000Z',
|
lastSeen: '2026-06-08T12:10:00.000Z',
|
||||||
address: 'bc1qtest',
|
address: 'bc1qtest',
|
||||||
|
payoutMode: 'pplns',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
addressSettingsService.getSettings.mockResolvedValue({ bestDifficulty: 4096 });
|
addressSettingsService.getSettings.mockResolvedValue({ bestDifficulty: 4096 });
|
||||||
@@ -154,30 +144,31 @@ describe('ClientController', () => {
|
|||||||
expect(payoutSnapshotService.getLatestExpectedPayoutForAddress).not.toHaveBeenCalled();
|
expect(payoutSnapshotService.getLatestExpectedPayoutForAddress).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should hide stale Redis workers that are no longer active in the database', async () => {
|
it('should expose active database workers for an address', async () => {
|
||||||
redisMessagingService.getClientPresenceByAddress.mockResolvedValue([
|
clientService.getByAddress.mockResolvedValue([
|
||||||
{
|
{
|
||||||
clientId: 'active-client',
|
id: 'active-client',
|
||||||
address: 'bc1qtest',
|
address: 'bc1qtest',
|
||||||
sessionId: 'active1',
|
sessionId: 'active1',
|
||||||
clientName: 'active-worker',
|
clientName: 'active-worker',
|
||||||
|
payoutMode: 'pplns',
|
||||||
bestDifficulty: 64,
|
bestDifficulty: 64,
|
||||||
hashRate: 1024,
|
hashRate: 1024,
|
||||||
startTime: '2026-06-08T12:00:00.000Z',
|
startTime: '2026-06-08T12:00:00.000Z',
|
||||||
lastSeen: '2026-06-08T12:10:00.000Z',
|
updatedAt: '2026-06-08T12:10:00.000Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
clientId: 'stale-client',
|
id: 'solo-client',
|
||||||
address: 'bc1qtest',
|
address: 'bc1qtest',
|
||||||
sessionId: 'stale1',
|
sessionId: 'solo1',
|
||||||
clientName: 'stale-worker',
|
clientName: 'solo-worker',
|
||||||
|
payoutMode: 'solo',
|
||||||
bestDifficulty: 128,
|
bestDifficulty: 128,
|
||||||
hashRate: 2048,
|
hashRate: 2048,
|
||||||
startTime: '2026-06-08T12:00:00.000Z',
|
startTime: '2026-06-08T12:00:00.000Z',
|
||||||
lastSeen: '2026-06-08T12:10:00.000Z',
|
updatedAt: '2026-06-08T12:10:00.000Z',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
clientService.getActiveIds.mockResolvedValue(new Set(['active-client']));
|
|
||||||
addressSettingsService.getSettings.mockResolvedValue(null);
|
addressSettingsService.getSettings.mockResolvedValue(null);
|
||||||
shareAccountingService.getSessionSummaries.mockResolvedValue(new Map());
|
shareAccountingService.getSessionSummaries.mockResolvedValue(new Map());
|
||||||
shareAccountingService.getAddressSummary.mockResolvedValue({
|
shareAccountingService.getAddressSummary.mockResolvedValue({
|
||||||
@@ -186,16 +177,16 @@ describe('ClientController', () => {
|
|||||||
bestSubmissionDifficulty: 0,
|
bestSubmissionDifficulty: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(controller.getClientInfo('bc1qtest')).resolves.toMatchObject({
|
await expect(controller.getClientInfo('bc1qtest', 'pplns')).resolves.toMatchObject({
|
||||||
workersCount: 1,
|
workersCount: 1,
|
||||||
workers: [
|
workers: [
|
||||||
{
|
{
|
||||||
sessionId: 'active1',
|
sessionId: 'active1',
|
||||||
name: 'active-worker',
|
name: 'active-worker',
|
||||||
|
payoutMode: 'pplns',
|
||||||
|
hashRate: 1024,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(redisMessagingService.removeClientPresence)
|
|
||||||
.toHaveBeenCalledWith('stale-client', 'bc1qtest');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ClientStatisticsService } from '../../ORM/client-statistics/client-stat
|
|||||||
import { ClientService } from '../../ORM/client/client.service';
|
import { ClientService } from '../../ORM/client/client.service';
|
||||||
import { PayoutSnapshotService } from '../../ORM/payout-snapshot/payout-snapshot.service';
|
import { PayoutSnapshotService } from '../../ORM/payout-snapshot/payout-snapshot.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';
|
|
||||||
import { normalizePayoutMode, PayoutMode } from '../../types/payout-mode';
|
import { normalizePayoutMode, PayoutMode } from '../../types/payout-mode';
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +17,6 @@ export class ClientController {
|
|||||||
private readonly addressSettingsService: AddressSettingsService,
|
private readonly addressSettingsService: AddressSettingsService,
|
||||||
private readonly shareAccountingService: ShareAccountingService,
|
private readonly shareAccountingService: ShareAccountingService,
|
||||||
private readonly payoutSnapshotService: PayoutSnapshotService,
|
private readonly payoutSnapshotService: PayoutSnapshotService,
|
||||||
private readonly redisMessagingService: RedisMessagingService
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
|
||||||
@@ -179,14 +177,20 @@ export class ClientController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getActiveAddressWorkers(address: string, payoutMode?: PayoutMode) {
|
private async getActiveAddressWorkers(address: string, payoutMode?: PayoutMode) {
|
||||||
const workers = await this.redisMessagingService.getClientPresenceByAddress(address);
|
const workers = await this.clientService.getByAddress(address);
|
||||||
const activeIds = await this.clientService.getActiveIds(workers.map(worker => worker.clientId));
|
return workers
|
||||||
const staleWorkers = workers.filter(worker => !activeIds.has(worker.clientId));
|
.filter(worker => payoutMode == null || worker.payoutMode === payoutMode)
|
||||||
void Promise.all(staleWorkers.map(worker => {
|
.map(worker => ({
|
||||||
return this.redisMessagingService.removeClientPresence(worker.clientId, worker.address)
|
clientId: worker.id,
|
||||||
.catch(() => undefined);
|
address: worker.address,
|
||||||
|
clientName: worker.clientName,
|
||||||
|
sessionId: worker.sessionId,
|
||||||
|
payoutMode: worker.payoutMode,
|
||||||
|
userAgent: worker.userAgent,
|
||||||
|
startTime: worker.startTime,
|
||||||
|
lastSeen: worker.updatedAt,
|
||||||
|
hashRate: Number(worker.hashRate ?? 0),
|
||||||
|
bestDifficulty: Number(worker.bestDifficulty ?? 0),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return workers.filter(worker => activeIds.has(worker.clientId) && (payoutMode == null || worker.payoutMode === payoutMode));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe('StratumV1Client', () => {
|
|||||||
let configService: ConfigService;
|
let configService: ConfigService;
|
||||||
let addressSettings: AddressSettingsService;
|
let addressSettings: AddressSettingsService;
|
||||||
let shareAccountingService: { recordAcceptedShare: jest.Mock };
|
let shareAccountingService: { recordAcceptedShare: jest.Mock };
|
||||||
let redisMessagingService: { setClientPresence: jest.Mock; removeClientPresence: jest.Mock };
|
let redisMessagingService: Record<string, never>;
|
||||||
|
|
||||||
let client: StratumV1Client;
|
let client: StratumV1Client;
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ describe('StratumV1Client', () => {
|
|||||||
}),
|
}),
|
||||||
connectedClientCount: jest.fn(async () => clients.size),
|
connectedClientCount: jest.fn(async () => clients.size),
|
||||||
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue({ affected: 1 }),
|
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
|
updateHashRate: jest.fn().mockResolvedValue(undefined),
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
configService = {
|
configService = {
|
||||||
@@ -132,10 +133,7 @@ describe('StratumV1Client', () => {
|
|||||||
shareAccountingService = {
|
shareAccountingService = {
|
||||||
recordAcceptedShare: jest.fn().mockResolvedValue(undefined),
|
recordAcceptedShare: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
redisMessagingService = {
|
redisMessagingService = {};
|
||||||
setClientPresence: jest.fn().mockResolvedValue(undefined),
|
|
||||||
removeClientPresence: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
client = new StratumV1Client(
|
client = new StratumV1Client(
|
||||||
@@ -185,11 +183,6 @@ describe('StratumV1Client', () => {
|
|||||||
|
|
||||||
await Promise.all([client.destroy(), client.destroy()]);
|
await Promise.all([client.destroy(), client.destroy()]);
|
||||||
|
|
||||||
expect(redisMessagingService.removeClientPresence).toHaveBeenCalledTimes(1);
|
|
||||||
expect(redisMessagingService.removeClientPresence).toHaveBeenCalledWith(
|
|
||||||
'00000000-0000-4000-8000-000000000001',
|
|
||||||
'tb1qcleanup',
|
|
||||||
);
|
|
||||||
expect(clientService.delete).toHaveBeenCalledTimes(1);
|
expect(clientService.delete).toHaveBeenCalledTimes(1);
|
||||||
expect(clientService.delete).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000001');
|
expect(clientService.delete).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000001');
|
||||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
@@ -415,13 +408,6 @@ describe('StratumV1Client', () => {
|
|||||||
creditedDifficulty: 0,
|
creditedDifficulty: 0,
|
||||||
isBlockCandidate: false,
|
isBlockCandidate: false,
|
||||||
}));
|
}));
|
||||||
expect(redisMessagingService.setClientPresence).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
|
||||||
clientName: 'bitaxe3',
|
|
||||||
sessionId: MockRecording1.EXTRA_NONCE,
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use the header-only fast path for non-block submissions', async () => {
|
it('should use the header-only fast path for non-block submissions', async () => {
|
||||||
|
|||||||
@@ -124,8 +124,6 @@ export class StratumV1Client {
|
|||||||
|
|
||||||
if (this.clientEntity?.id) {
|
if (this.clientEntity?.id) {
|
||||||
const clientId = this.clientEntity.id;
|
const clientId = this.clientEntity.id;
|
||||||
const address = this.clientEntity.address;
|
|
||||||
await this.redisMessagingService?.removeClientPresence(clientId, address);
|
|
||||||
await this.clientService.delete(clientId);
|
await this.clientService.delete(clientId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,36 +525,12 @@ export class StratumV1Client {
|
|||||||
payoutMode: this.payoutMode,
|
payoutMode: this.payoutMode,
|
||||||
bestDifficulty: 0
|
bestDifficulty: 0
|
||||||
});
|
});
|
||||||
await this.updateClientPresence(new Date());
|
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.creatingEntity;
|
await this.creatingEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateClientPresence(lastSeen: Date): Promise<void> {
|
|
||||||
if (this.clientEntity == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.redisMessagingService?.setClientPresence({
|
|
||||||
clientId: this.clientEntity.id,
|
|
||||||
address: this.clientEntity.address,
|
|
||||||
clientName: this.clientEntity.clientName,
|
|
||||||
sessionId: this.clientEntity.sessionId,
|
|
||||||
payoutMode: this.payoutMode,
|
|
||||||
userAgent: this.clientEntity.userAgent,
|
|
||||||
startTime: new Date(this.clientEntity.startTime).toISOString(),
|
|
||||||
lastSeen: lastSeen.toISOString(),
|
|
||||||
hashRate: this.statistics?.hashRate ?? 0,
|
|
||||||
bestDifficulty: Number(this.clientEntity.bestDifficulty ?? 0),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to update SV1 client presence: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleMiningSubmission(submission: MiningSubmitMessage) {
|
private async handleMiningSubmission(submission: MiningSubmitMessage) {
|
||||||
|
|
||||||
const job = this.stratumV1JobsService.getJobById(submission.jobId);
|
const job = this.stratumV1JobsService.getJobById(submission.jobId);
|
||||||
@@ -707,7 +681,6 @@ export class StratumV1Client {
|
|||||||
this.clientEntity.updatedAt = now;
|
this.clientEntity.updatedAt = now;
|
||||||
this.clientEntity.hashRate = this.statistics.hashRate;
|
this.clientEntity.hashRate = this.statistics.hashRate;
|
||||||
await this.persistClientHashRate(now);
|
await this.persistClientHashRate(now);
|
||||||
await this.updateClientPresence(now);
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
@@ -717,7 +690,6 @@ export class StratumV1Client {
|
|||||||
await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty);
|
await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty);
|
||||||
this.clientEntity.bestDifficulty = submissionDifficulty;
|
this.clientEntity.bestDifficulty = submissionDifficulty;
|
||||||
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent);
|
await this.addressSettingsService.updateBestDifficultyIfHigher(this.clientAuthorization.address, submissionDifficulty, this.clientEntity.userAgent);
|
||||||
await this.updateClientPresence(new Date());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -301,8 +301,8 @@ describe('StratumV2Client extended channels', () => {
|
|||||||
expect((client as any).channels.get(1).extendedJobs.has(100)).toBe(false);
|
expect((client as any).channels.get(1).extendedJobs.has(100)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records accepted SV2 shares before presence updates', async () => {
|
it('records accepted SV2 shares and persists DB hashrate', async () => {
|
||||||
const { client, shareAccountingService, redisMessagingService, jobTemplate } = await createClient();
|
const { client, clientService, shareAccountingService, jobTemplate } = await createClient();
|
||||||
(client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
|
(client as any).address = 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4';
|
||||||
(client as any).workerName = 'worker';
|
(client as any).workerName = 'worker';
|
||||||
(client as any).sessionId = 'sv2-session';
|
(client as any).sessionId = 'sv2-session';
|
||||||
@@ -333,10 +333,6 @@ describe('StratumV2Client extended channels', () => {
|
|||||||
nonce: 123,
|
nonce: 123,
|
||||||
extraNonce2: 'c708000000000000',
|
extraNonce2: 'c708000000000000',
|
||||||
}));
|
}));
|
||||||
const accountingCallOrder = shareAccountingService.recordAcceptedShare.mock.invocationCallOrder[0];
|
|
||||||
const postAccountingPresenceUpdates = redisMessagingService.setClientPresence.mock.invocationCallOrder
|
|
||||||
.filter(callOrder => callOrder > accountingCallOrder);
|
|
||||||
expect(postAccountingPresenceUpdates.length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not submit a block when only the reported SV2 difficulty is huge', async () => {
|
it('does not submit a block when only the reported SV2 difficulty is huge', async () => {
|
||||||
@@ -422,11 +418,12 @@ describe('StratumV2Client extended channels', () => {
|
|||||||
client: StratumV2Client;
|
client: StratumV2Client;
|
||||||
sentFrames: any[];
|
sentFrames: any[];
|
||||||
bitcoinRpcService: { SUBMIT_BLOCK: jest.Mock };
|
bitcoinRpcService: { SUBMIT_BLOCK: jest.Mock };
|
||||||
|
clientService: { updateHashRate: jest.Mock };
|
||||||
blocksService: { save: jest.Mock };
|
blocksService: { save: jest.Mock };
|
||||||
notificationService: { notifySubscribersBlockFound: jest.Mock };
|
notificationService: { notifySubscribersBlockFound: jest.Mock };
|
||||||
addressSettingsService: { resetBestDifficultyAndShares: jest.Mock; updateBestDifficultyIfHigher: jest.Mock };
|
addressSettingsService: { resetBestDifficultyAndShares: jest.Mock; updateBestDifficultyIfHigher: jest.Mock };
|
||||||
shareAccountingService: { recordAcceptedShare: jest.Mock };
|
shareAccountingService: { recordAcceptedShare: jest.Mock };
|
||||||
redisMessagingService: { setClientPresence: jest.Mock; removeClientPresence: jest.Mock };
|
redisMessagingService: Record<string, never>;
|
||||||
jobTemplate: any;
|
jobTemplate: any;
|
||||||
}> {
|
}> {
|
||||||
const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
|
const blockTemplate$ = new BehaviorSubject(MockRecording1.BLOCK_TEMPLATE);
|
||||||
@@ -469,6 +466,7 @@ describe('StratumV2Client extended channels', () => {
|
|||||||
insert: jest.fn().mockResolvedValue(clientEntity),
|
insert: jest.fn().mockResolvedValue(clientEntity),
|
||||||
delete: jest.fn().mockResolvedValue(undefined),
|
delete: jest.fn().mockResolvedValue(undefined),
|
||||||
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
|
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
|
||||||
|
updateHashRate: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const shareAccountingService = {
|
const shareAccountingService = {
|
||||||
recordAcceptedShare: jest.fn().mockResolvedValue(undefined),
|
recordAcceptedShare: jest.fn().mockResolvedValue(undefined),
|
||||||
@@ -479,10 +477,7 @@ describe('StratumV2Client extended channels', () => {
|
|||||||
const blocksService = {
|
const blocksService = {
|
||||||
save: jest.fn().mockResolvedValue(undefined),
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const redisMessagingService = {
|
const redisMessagingService = {};
|
||||||
setClientPresence: jest.fn().mockResolvedValue(undefined),
|
|
||||||
removeClientPresence: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
const addressSettingsService = {
|
const addressSettingsService = {
|
||||||
resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined),
|
resetBestDifficultyAndShares: jest.fn().mockResolvedValue(undefined),
|
||||||
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
|
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
|
||||||
@@ -548,6 +543,7 @@ describe('StratumV2Client extended channels', () => {
|
|||||||
client,
|
client,
|
||||||
sentFrames,
|
sentFrames,
|
||||||
bitcoinRpcService,
|
bitcoinRpcService,
|
||||||
|
clientService,
|
||||||
blocksService,
|
blocksService,
|
||||||
notificationService,
|
notificationService,
|
||||||
addressSettingsService,
|
addressSettingsService,
|
||||||
|
|||||||
@@ -191,7 +191,6 @@ export class StratumV2Client {
|
|||||||
}
|
}
|
||||||
this.channels.clear();
|
this.channels.clear();
|
||||||
if (this.clientEntity?.id != null) {
|
if (this.clientEntity?.id != null) {
|
||||||
await this.redisMessagingService?.removeClientPresence(this.clientEntity.id, this.clientEntity.address);
|
|
||||||
await this.clientService.delete(this.clientEntity.id);
|
await this.clientService.delete(this.clientEntity.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -937,7 +936,6 @@ export class StratumV2Client {
|
|||||||
this.clientEntity.updatedAt = now;
|
this.clientEntity.updatedAt = now;
|
||||||
this.clientEntity.hashRate = this.statistics.hashRate;
|
this.clientEntity.hashRate = this.statistics.hashRate;
|
||||||
await this.persistClientHashRate(now);
|
await this.persistClientHashRate(now);
|
||||||
await this.updateClientPresence(now);
|
|
||||||
|
|
||||||
if (submissionDifficulty > this.clientEntity.bestDifficulty) {
|
if (submissionDifficulty > this.clientEntity.bestDifficulty) {
|
||||||
await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty);
|
await this.clientService.updateBestDifficultyIfHigher(this.clientEntity.id, submissionDifficulty);
|
||||||
@@ -947,7 +945,6 @@ export class StratumV2Client {
|
|||||||
submissionDifficulty,
|
submissionDifficulty,
|
||||||
this.userAgent,
|
this.userAgent,
|
||||||
);
|
);
|
||||||
await this.updateClientPresence(new Date());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1316,36 +1313,12 @@ export class StratumV2Client {
|
|||||||
payoutMode: this.payoutMode,
|
payoutMode: this.payoutMode,
|
||||||
bestDifficulty: 0,
|
bestDifficulty: 0,
|
||||||
});
|
});
|
||||||
await this.updateClientPresence(new Date());
|
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.creatingEntity;
|
await this.creatingEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateClientPresence(lastSeen: Date): Promise<void> {
|
|
||||||
if (this.clientEntity == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.redisMessagingService?.setClientPresence({
|
|
||||||
clientId: this.clientEntity.id,
|
|
||||||
address: this.clientEntity.address,
|
|
||||||
clientName: this.clientEntity.clientName,
|
|
||||||
sessionId: this.clientEntity.sessionId,
|
|
||||||
payoutMode: this.payoutMode,
|
|
||||||
userAgent: this.clientEntity.userAgent,
|
|
||||||
startTime: new Date(this.clientEntity.startTime).toISOString(),
|
|
||||||
lastSeen: lastSeen.toISOString(),
|
|
||||||
hashRate: this.statistics.hashRate,
|
|
||||||
bestDifficulty: Number(this.clientEntity.bestDifficulty ?? 0),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to update SV2 client presence: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistClientHashRate(now: Date): Promise<void> {
|
private async persistClientHashRate(now: Date): Promise<void> {
|
||||||
if (this.clientEntity?.id == null) {
|
if (this.clientEntity?.id == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -175,16 +175,13 @@ describe('DatumService job validation', () => {
|
|||||||
expect(socket.write).not.toHaveBeenCalled();
|
expect(socket.write).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('publishes DATUM client presence with smoothed hashrate after accepted shares', async () => {
|
it('persists DATUM client hashrate after accepted shares', async () => {
|
||||||
jest.useFakeTimers().setSystemTime(new Date('2026-06-13T15:00:00.000Z'));
|
jest.useFakeTimers().setSystemTime(new Date('2026-06-13T15:00:00.000Z'));
|
||||||
const clientService = {
|
const clientService = {
|
||||||
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
|
updateBestDifficultyIfHigher: jest.fn().mockResolvedValue(undefined),
|
||||||
updateHashRate: jest.fn().mockResolvedValue(undefined),
|
updateHashRate: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const redisMessagingService = {
|
const service = createService({ clientService }) as any;
|
||||||
setClientPresence: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
const service = createService({ clientService, redisMessagingService }) as any;
|
|
||||||
const state = {
|
const state = {
|
||||||
sessionId: 'datum-session',
|
sessionId: 'datum-session',
|
||||||
userAgent: 'datum/test',
|
userAgent: 'datum/test',
|
||||||
@@ -211,20 +208,10 @@ describe('DatumService job validation', () => {
|
|||||||
'3db0db03-3a62-4e3b-91bc-243adff4b542',
|
'3db0db03-3a62-4e3b-91bc-243adff4b542',
|
||||||
30,
|
30,
|
||||||
);
|
);
|
||||||
expect(redisMessagingService.setClientPresence).toHaveBeenLastCalledWith(expect.objectContaining({
|
expect(state.clientEntity.hashRate).toBeGreaterThan(0);
|
||||||
clientId: '3db0db03-3a62-4e3b-91bc-243adff4b542',
|
|
||||||
address: 'tb1qdatum',
|
|
||||||
clientName: 'datum-worker',
|
|
||||||
sessionId: 'datum-session',
|
|
||||||
hashRate: expect.any(Number),
|
|
||||||
bestDifficulty: 30,
|
|
||||||
}));
|
|
||||||
const lastPresence = redisMessagingService.setClientPresence.mock.calls.at(-1)[0];
|
|
||||||
expect(lastPresence.hashRate).toBeGreaterThan(0);
|
|
||||||
expect(state.clientEntity.hashRate).toBe(lastPresence.hashRate);
|
|
||||||
expect(clientService.updateHashRate).toHaveBeenCalledWith(
|
expect(clientService.updateHashRate).toHaveBeenCalledWith(
|
||||||
'3db0db03-3a62-4e3b-91bc-243adff4b542',
|
'3db0db03-3a62-4e3b-91bc-243adff4b542',
|
||||||
lastPresence.hashRate,
|
state.clientEntity.hashRate,
|
||||||
new Date('2026-06-13T15:01:02.000Z'),
|
new Date('2026-06-13T15:01:02.000Z'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -396,22 +396,6 @@ export class DatumService implements OnModuleInit {
|
|||||||
await this.clientService.updateBestDifficultyIfHigher(state.clientEntity.id, submissionDifficulty);
|
await this.clientService.updateBestDifficultyIfHigher(state.clientEntity.id, submissionDifficulty);
|
||||||
state.clientEntity.bestDifficulty = submissionDifficulty;
|
state.clientEntity.bestDifficulty = submissionDifficulty;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.redisMessagingService?.setClientPresence({
|
|
||||||
clientId: state.clientEntity.id,
|
|
||||||
address,
|
|
||||||
clientName: workerName,
|
|
||||||
sessionId: state.sessionId,
|
|
||||||
payoutMode: state.payoutMode,
|
|
||||||
userAgent: state.userAgent,
|
|
||||||
startTime: new Date(state.clientEntity.startTime).toISOString(),
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
hashRate: state.statistics.hashRate,
|
|
||||||
bestDifficulty: Math.max(
|
|
||||||
Number(state.clientEntity.bestDifficulty ?? 0),
|
|
||||||
submissionDifficulty,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateDatumJobCache(state: DatumClientState, pow: DatumPowSubmit): DatumJobCache {
|
private updateDatumJobCache(state: DatumClientState, pow: DatumPowSubmit): DatumJobCache {
|
||||||
@@ -751,30 +735,6 @@ export class DatumService implements OnModuleInit {
|
|||||||
payoutMode: state.payoutMode,
|
payoutMode: state.payoutMode,
|
||||||
bestDifficulty: 0,
|
bestDifficulty: 0,
|
||||||
});
|
});
|
||||||
await this.updateClientPresence(state, new Date());
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateClientPresence(state: DatumClientState, lastSeen: Date): Promise<void> {
|
|
||||||
if (state.clientEntity == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.redisMessagingService?.setClientPresence({
|
|
||||||
clientId: state.clientEntity.id,
|
|
||||||
address: state.clientEntity.address,
|
|
||||||
clientName: state.clientEntity.clientName,
|
|
||||||
sessionId: state.clientEntity.sessionId,
|
|
||||||
payoutMode: state.payoutMode,
|
|
||||||
userAgent: state.clientEntity.userAgent,
|
|
||||||
startTime: new Date(state.clientEntity.startTime).toISOString(),
|
|
||||||
lastSeen: lastSeen.toISOString(),
|
|
||||||
hashRate: state.statistics.hashRate,
|
|
||||||
bestDifficulty: Number(state.clientEntity.bestDifficulty ?? 0),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to update DATUM client presence: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistClientHashRate(state: DatumClientState, now: Date): Promise<void> {
|
private async persistClientHashRate(state: DatumClientState, now: Date): Promise<void> {
|
||||||
@@ -815,7 +775,6 @@ export class DatumService implements OnModuleInit {
|
|||||||
if (state.clientEntity?.id == null) {
|
if (state.clientEntity?.id == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.redisMessagingService?.removeClientPresence(state.clientEntity.id, state.clientEntity.address);
|
|
||||||
await this.clientService.delete(state.clientEntity.id);
|
await this.clientService.delete(state.clientEntity.id);
|
||||||
state.clientEntity = null;
|
state.clientEntity = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,80 +61,6 @@ describe('RedisMessagingService', () => {
|
|||||||
consoleSpy.mockRestore();
|
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not return address presence that is no longer in the active global set', 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,
|
|
||||||
});
|
|
||||||
sets.get('client-presence:all')?.delete('00000000-0000-4000-8000-000000000001');
|
|
||||||
|
|
||||||
expect(await service.getClientPresenceByAddress('tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'))
|
|
||||||
.toHaveLength(0);
|
|
||||||
expect(sets.get('client-presence:address:tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4'))
|
|
||||||
?.not.toContain('00000000-0000-4000-8000-000000000001');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = new Map<string, string>();
|
const store = new Map<string, string>();
|
||||||
|
|||||||
@@ -4,45 +4,22 @@ import { createClient, RedisClientType } from 'redis';
|
|||||||
|
|
||||||
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
import { IBlockTemplate } from '../models/bitcoin-rpc/IBlockTemplate';
|
||||||
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
|
import { IMiningInfo } from '../models/bitcoin-rpc/IMiningInfo';
|
||||||
import { normalizePayoutMode, PayoutMode } from '../types/payout-mode';
|
|
||||||
|
|
||||||
const MINING_INFO_CHANNEL = 'mining-info.updated';
|
const MINING_INFO_CHANNEL = 'mining-info.updated';
|
||||||
const MINING_INFO_KEY = 'mining-info:latest';
|
const MINING_INFO_KEY = 'mining-info:latest';
|
||||||
const BLOCK_TEMPLATE_LATEST_KEY = 'block-template:latest';
|
const BLOCK_TEMPLATE_LATEST_KEY = 'block-template:latest';
|
||||||
const CLIENT_PRESENCE_TTL_SECONDS = 180;
|
|
||||||
const blockTemplateKey = (height: number) => `block-template:${height}`;
|
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}`;
|
|
||||||
const jsonCacheKey = (key: string) => `json-cache:${key}`;
|
const jsonCacheKey = (key: string) => `json-cache:${key}`;
|
||||||
|
|
||||||
export interface ClientPresence {
|
|
||||||
clientId: string;
|
|
||||||
address: string;
|
|
||||||
clientName: string;
|
|
||||||
sessionId: string;
|
|
||||||
payoutMode?: PayoutMode;
|
|
||||||
userAgent?: string | null;
|
|
||||||
startTime: string;
|
|
||||||
lastSeen: string;
|
|
||||||
hashRate: number;
|
|
||||||
bestDifficulty: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
||||||
private publisher: RedisClientType;
|
private publisher: RedisClientType;
|
||||||
private subscriber: RedisClientType;
|
private subscriber: RedisClientType;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private readonly clientPresenceTtlSeconds: number;
|
|
||||||
private readonly clientPresenceEnabled: boolean;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {
|
) { }
|
||||||
this.clientPresenceTtlSeconds = this.readPositiveInt('CLIENT_PRESENCE_TTL_SECONDS', CLIENT_PRESENCE_TTL_SECONDS);
|
|
||||||
this.clientPresenceEnabled = this.configService.get<string>('CLIENT_PRESENCE_ENABLED') !== 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
public async onModuleInit() {
|
public async onModuleInit() {
|
||||||
await this.connect().catch(error => {
|
await this.connect().catch(error => {
|
||||||
@@ -140,108 +117,6 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return value == null ? null : JSON.parse(value as string);
|
return value == null ? null : JSON.parse(value as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setClientPresence(presence: ClientPresence): Promise<void> {
|
|
||||||
if (!this.clientPresenceEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!await this.ensureConnected()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serialized = JSON.stringify({
|
|
||||||
...presence,
|
|
||||||
payoutMode: normalizePayoutMode(presence.payoutMode),
|
|
||||||
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 (!this.clientPresenceEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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 (!this.clientPresenceEnabled) {
|
|
||||||
return 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 (!this.clientPresenceEnabled) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (!await this.ensureConnected()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.getPresenceFromSet(clientPresenceAddressKey(address));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getAllClientPresence(): Promise<ClientPresence[]> {
|
|
||||||
if (!this.clientPresenceEnabled) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (!await this.ensureConnected()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.getPresenceFromSet(CLIENT_PRESENCE_ALL_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async clearClientPresence(): Promise<void> {
|
|
||||||
if (!this.clientPresenceEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getJsonCache<T>(key: string): Promise<T | null> {
|
public async getJsonCache<T>(key: string): Promise<T | null> {
|
||||||
if (!await this.ensureConnected()) {
|
if (!await this.ensureConnected()) {
|
||||||
return null;
|
return null;
|
||||||
@@ -285,86 +160,4 @@ export class RedisMessagingService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPresenceFromSet(setKey: string): Promise<ClientPresence[]> {
|
|
||||||
const clientIds = await this.publisher.sMembers(setKey);
|
|
||||||
if (clientIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const activeClientIds = setKey === CLIENT_PRESENCE_ALL_KEY
|
|
||||||
? null
|
|
||||||
: new Set(await this.publisher.sMembers(CLIENT_PRESENCE_ALL_KEY));
|
|
||||||
|
|
||||||
const presences: ClientPresence[] = [];
|
|
||||||
const staleClientIds: string[] = [];
|
|
||||||
for (let i = 0; i < clientIds.length; i += 1000) {
|
|
||||||
const chunk = clientIds.slice(i, i + 1000);
|
|
||||||
const values = await this.publisher.mGet(chunk.map(clientPresenceKey));
|
|
||||||
values.forEach((value, index) => {
|
|
||||||
const clientId = chunk[index];
|
|
||||||
if (activeClientIds != null && !activeClientIds.has(clientId)) {
|
|
||||||
staleClientIds.push(clientId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const presence = this.parseClientPresence(value);
|
|
||||||
if (presence == null) {
|
|
||||||
staleClientIds.push(clientId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
presences.push(presence);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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,
|
|
||||||
payoutMode: normalizePayoutMode(parsed.payoutMode),
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ describe('StratumV1Service', () => {
|
|||||||
ensureInitialized: jest.fn().mockResolvedValue(undefined),
|
ensureInitialized: jest.fn().mockResolvedValue(undefined),
|
||||||
createClient: jest.fn()
|
createClient: jest.fn()
|
||||||
};
|
};
|
||||||
redisMessagingService = {
|
redisMessagingService = {};
|
||||||
clearClientPresence: jest.fn().mockResolvedValue(undefined)
|
|
||||||
};
|
|
||||||
service = new StratumV1Service(
|
service = new StratumV1Service(
|
||||||
{} as any,
|
{} as any,
|
||||||
clientService,
|
clientService,
|
||||||
@@ -75,7 +73,6 @@ describe('StratumV1Service', () => {
|
|||||||
jest.runOnlyPendingTimers();
|
jest.runOnlyPendingTimers();
|
||||||
|
|
||||||
expect(clientService.deleteAll).toHaveBeenCalled();
|
expect(clientService.deleteAll).toHaveBeenCalled();
|
||||||
expect(redisMessagingService.clearClientPresence).toHaveBeenCalled();
|
|
||||||
expect(userAgentReportService.refreshReport).toHaveBeenCalled();
|
expect(userAgentReportService.refreshReport).toHaveBeenCalled();
|
||||||
expect(startSocketServerSpy).not.toHaveBeenCalled();
|
expect(startSocketServerSpy).not.toHaveBeenCalled();
|
||||||
expect(startSecureSocketServerSpy).not.toHaveBeenCalled();
|
expect(startSecureSocketServerSpy).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ export class StratumV1Service implements OnModuleInit {
|
|||||||
|
|
||||||
if (process.env.MASTER == 'true') {
|
if (process.env.MASTER == 'true') {
|
||||||
await this.clientService.deleteAll();
|
await this.clientService.deleteAll();
|
||||||
await this.redisMessagingService?.clearClientPresence();
|
|
||||||
await this.userAgentReportService.refreshReport();
|
await this.userAgentReportService.refreshReport();
|
||||||
console.log('Master process skipping Stratum socket listeners');
|
console.log('Master process skipping Stratum socket listeners');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ describe('TimescaleDB and Redis integration', () => {
|
|||||||
await dataSource.query(`DELETE FROM blocks_entity`);
|
await dataSource.query(`DELETE FROM blocks_entity`);
|
||||||
await dataSource.query(`DELETE FROM client_entity`);
|
await dataSource.query(`DELETE FROM client_entity`);
|
||||||
await dataSource.query(`REFRESH MATERIALIZED VIEW user_agent_report_view`);
|
await dataSource.query(`REFRESH MATERIALIZED VIEW user_agent_report_view`);
|
||||||
await redisMessagingService.clearClientPresence();
|
|
||||||
await (redisMessagingService as any).publisher.del('json-cache:presence:user-agent-report');
|
await (redisMessagingService as any).publisher.del('json-cache:presence:user-agent-report');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -613,7 +612,7 @@ describe('TimescaleDB and Redis integration', () => {
|
|||||||
bestDifficulty: 11,
|
bestDifficulty: 11,
|
||||||
hashRate: 0,
|
hashRate: 0,
|
||||||
});
|
});
|
||||||
await repository.save({
|
const staleClient = await repository.save({
|
||||||
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
address: 'tb1qumezefzdeqqwn5zfvgdrhxjzc5ylr39uhuxcz4',
|
||||||
clientName: 'stale-live-worker',
|
clientName: 'stale-live-worker',
|
||||||
sessionId: '92b2c3d4',
|
sessionId: '92b2c3d4',
|
||||||
@@ -622,18 +621,7 @@ describe('TimescaleDB and Redis integration', () => {
|
|||||||
bestDifficulty: 22,
|
bestDifficulty: 22,
|
||||||
hashRate: 200,
|
hashRate: 200,
|
||||||
});
|
});
|
||||||
|
await repository.softDelete(staleClient.id);
|
||||||
await redisMessagingService.setClientPresence({
|
|
||||||
clientId: activeClient.id,
|
|
||||||
address: activeClient.address,
|
|
||||||
clientName: activeClient.clientName,
|
|
||||||
sessionId: activeClient.sessionId,
|
|
||||||
userAgent,
|
|
||||||
startTime: activeClient.startTime.toISOString(),
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
bestDifficulty: Number(activeClient.bestDifficulty),
|
|
||||||
hashRate: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = await reportService.getReport();
|
const rows = await reportService.getReport();
|
||||||
expect(rows).toEqual(expect.arrayContaining([{
|
expect(rows).toEqual(expect.arrayContaining([{
|
||||||
|
|||||||
Reference in New Issue
Block a user