Remove Redis client presence

This commit is contained in:
Ben
2026-06-20 16:03:34 -04:00
parent cd0cb9bfe0
commit d1fb186e5d
20 changed files with 111 additions and 613 deletions
@@ -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';
const userAgentReport = {
find: jest.fn().mockResolvedValue([
@@ -23,26 +23,17 @@ describe('UserAgentReportService', () => {
},
]),
};
const clientRepository = {
find: jest.fn().mockResolvedValue([{ id: 'client-1' }]),
};
const clientRepository = createClientRepository([
{
userAgent: 'unknown/sv2',
count: '1',
bestDifficulty: 456,
totalHashRate: '123',
},
]);
const redisMessagingService = {
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),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
};
const service = new UserAgentReportService(
userAgentReport as any,
@@ -59,9 +50,10 @@ describe('UserAgentReportService', () => {
},
]);
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';
const viewRows = [
{
@@ -74,14 +66,10 @@ describe('UserAgentReportService', () => {
const userAgentReport = {
find: jest.fn().mockResolvedValue(viewRows),
};
const clientRepository = {
find: jest.fn().mockResolvedValue([]),
};
const clientRepository = createClientRepository([]);
const redisMessagingService = {
getJsonCache: jest.fn().mockResolvedValue([]),
getAllClientPresence: jest.fn().mockResolvedValue([]),
setJsonCache: jest.fn().mockResolvedValue(undefined),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
};
const service = new UserAgentReportService(
userAgentReport as any,
@@ -92,42 +80,23 @@ describe('UserAgentReportService', () => {
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';
const cachedRows = [
{
userAgent: 'cached',
count: '2',
bestDifficulty: 8,
totalHashRate: '16',
},
];
const userAgentReport = {
find: jest.fn().mockResolvedValue([]),
};
const clientRepository = {
find: jest.fn().mockResolvedValue([{ id: 'active-client' }]),
find: jest.fn(),
};
const clientRepository = createClientRepository([]);
const redisMessagingService = {
getJsonCache: jest.fn().mockResolvedValue(null),
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,
},
]),
getJsonCache: jest.fn().mockResolvedValue(cachedRows),
setJsonCache: jest.fn().mockResolvedValue(undefined),
removeClientPresence: jest.fn().mockResolvedValue(undefined),
};
const service = new UserAgentReportService(
userAgentReport as any,
@@ -135,15 +104,22 @@ describe('UserAgentReportService', () => {
redisMessagingService as any,
);
await expect(service.getReport()).resolves.toEqual([
{
userAgent: 'bitaxe',
count: '1',
bestDifficulty: 200,
totalHashRate: '100',
},
]);
expect(redisMessagingService.removeClientPresence)
.toHaveBeenCalledWith('stale-client', 'tb1qstale');
await expect(service.getReport()).resolves.toEqual(cachedRows);
expect(clientRepository.createQueryBuilder).not.toHaveBeenCalled();
});
});
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 { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { ClientEntity } from '../../client/client.entity';
import { UserAgentReportView } from './user-agent-report.view';
@@ -60,76 +60,28 @@ export class UserAgentReportService {
}
private async buildLiveReport() {
const presences = await this.redisMessagingService.getAllClientPresence();
const activePresences = await this.filterActivePresences(presences);
if (activePresences.length === 0) {
const rows = await this.clientRepository
.createQueryBuilder('client')
.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();
}
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
.setJsonCache(this.liveReportCacheKey, report, 60 * 1000)
.setJsonCache(this.liveReportCacheKey, rows, 60 * 1000)
.catch(error => {
console.error(`Live user-agent report cache write failed: ${error.message}`);
});
return report;
}
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));
return rows;
}
public async refreshReport() {