fix: add metrics
This commit is contained in:
parent
62ac1e4132
commit
35299fbbdf
4
.env.dev
4
.env.dev
@ -2,5 +2,9 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/handy
|
|||||||
HANDY_MASTER_SECRET=your-super-secret-key-for-local-development
|
HANDY_MASTER_SECRET=your-super-secret-key-for-local-development
|
||||||
PORT=3005
|
PORT=3005
|
||||||
|
|
||||||
|
# Metrics server configuration
|
||||||
|
METRICS_ENABLED=true
|
||||||
|
METRICS_PORT=9090
|
||||||
|
|
||||||
# Uncomment to enable centralized logging for AI debugging (creates .logs directory)
|
# Uncomment to enable centralized logging for AI debugging (creates .logs directory)
|
||||||
DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING=true
|
DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING=true
|
||||||
|
@ -47,6 +47,7 @@
|
|||||||
"prisma": "^6.11.1",
|
"prisma": "^6.11.1",
|
||||||
"prisma-json-types-generator": "^3.5.1",
|
"prisma-json-types-generator": "^3.5.1",
|
||||||
"privacy-kit": "^0.0.23",
|
"privacy-kit": "^0.0.23",
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"socket.io-adapter": "^2.5.5",
|
"socket.io-adapter": "^2.5.5",
|
||||||
"tmp": "^0.2.3",
|
"tmp": "^0.2.3",
|
||||||
@ -58,4 +59,4 @@
|
|||||||
"zod": "^3.24.2",
|
"zod": "^3.24.2",
|
||||||
"zod-to-json-schema": "^3.24.3"
|
"zod-to-json-schema": "^3.24.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,14 @@ import {
|
|||||||
buildUsageEphemeral,
|
buildUsageEphemeral,
|
||||||
buildMachineStatusEphemeral
|
buildMachineStatusEphemeral
|
||||||
} from "@/modules/eventRouter";
|
} from "@/modules/eventRouter";
|
||||||
|
import {
|
||||||
|
incrementWebSocketConnection,
|
||||||
|
decrementWebSocketConnection,
|
||||||
|
sessionAliveEventsCounter,
|
||||||
|
machineAliveEventsCounter,
|
||||||
|
websocketEventsCounter
|
||||||
|
} from "@/modules/metrics";
|
||||||
|
import { activityCache } from "@/modules/sessionCache";
|
||||||
|
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@ -1223,6 +1231,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
eventRouter.addConnection(userId, connection);
|
eventRouter.addConnection(userId, connection);
|
||||||
|
incrementWebSocketConnection(connection.connectionType);
|
||||||
|
|
||||||
// Broadcast daemon online status
|
// Broadcast daemon online status
|
||||||
if (connection.connectionType === 'machine-scoped') {
|
if (connection.connectionType === 'machine-scoped') {
|
||||||
@ -1240,8 +1249,11 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
|||||||
const receiveUsageLock = new AsyncLock();
|
const receiveUsageLock = new AsyncLock();
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
|
websocketEventsCounter.inc({ event_type: 'disconnect' });
|
||||||
|
|
||||||
// Cleanup connections
|
// Cleanup connections
|
||||||
eventRouter.removeConnection(userId, connection);
|
eventRouter.removeConnection(userId, connection);
|
||||||
|
decrementWebSocketConnection(connection.connectionType);
|
||||||
|
|
||||||
// Clean up RPC listeners for this socket
|
// Clean up RPC listeners for this socket
|
||||||
const userRpcMap = rpcListeners.get(userId);
|
const userRpcMap = rpcListeners.get(userId);
|
||||||
@ -1284,6 +1296,10 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
|||||||
thinking?: boolean;
|
thinking?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
|
// Track metrics
|
||||||
|
websocketEventsCounter.inc({ event_type: 'session-alive' });
|
||||||
|
sessionAliveEventsCounter.inc();
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if (!data || typeof data.time !== 'number' || !data.sid) {
|
if (!data || typeof data.time !== 'number' || !data.sid) {
|
||||||
return;
|
return;
|
||||||
@ -1299,19 +1315,14 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
|||||||
|
|
||||||
const { sid, thinking } = data;
|
const { sid, thinking } = data;
|
||||||
|
|
||||||
// Resolve session
|
// Check session validity using cache
|
||||||
const session = await db.session.findUnique({
|
const isValid = await activityCache.isSessionValid(sid, userId);
|
||||||
where: { id: sid, accountId: userId }
|
if (!isValid) {
|
||||||
});
|
|
||||||
if (!session) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last active
|
// Queue database update (will only update if time difference is significant)
|
||||||
await db.session.update({
|
activityCache.queueSessionUpdate(sid, t);
|
||||||
where: { id: sid },
|
|
||||||
data: { lastActiveAt: new Date(t), active: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emit session activity update
|
// Emit session activity update
|
||||||
const sessionActivity = buildSessionActivityEphemeral(sid, true, t, thinking || false);
|
const sessionActivity = buildSessionActivityEphemeral(sid, true, t, thinking || false);
|
||||||
@ -1330,6 +1341,10 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
|||||||
time: number;
|
time: number;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
|
// Track metrics
|
||||||
|
websocketEventsCounter.inc({ event_type: 'machine-alive' });
|
||||||
|
machineAliveEventsCounter.inc();
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if (!data || typeof data.time !== 'number' || !data.machineId) {
|
if (!data || typeof data.time !== 'number' || !data.machineId) {
|
||||||
return;
|
return;
|
||||||
@ -1343,35 +1358,16 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve machine
|
// Check machine validity using cache
|
||||||
const machine = await db.machine.findUnique({
|
const isValid = await activityCache.isMachineValid(data.machineId, userId);
|
||||||
where: {
|
if (!isValid) {
|
||||||
accountId_id: {
|
|
||||||
accountId: userId,
|
|
||||||
id: data.machineId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!machine) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update machine lastActiveAt in database
|
// Queue database update (will only update if time difference is significant)
|
||||||
const updatedMachine = await db.machine.update({
|
activityCache.queueMachineUpdate(data.machineId, t);
|
||||||
where: {
|
|
||||||
accountId_id: {
|
|
||||||
accountId: userId,
|
|
||||||
id: data.machineId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
lastActiveAt: new Date(t),
|
|
||||||
active: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const machineActivity = buildMachineActivityEphemeral(updatedMachine.id, true, t);
|
const machineActivity = buildMachineActivityEphemeral(data.machineId, true, t);
|
||||||
eventRouter.emitEphemeral({
|
eventRouter.emitEphemeral({
|
||||||
userId,
|
userId,
|
||||||
payload: machineActivity,
|
payload: machineActivity,
|
||||||
@ -1428,6 +1424,7 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
|
|||||||
socket.on('message', async (data: any) => {
|
socket.on('message', async (data: any) => {
|
||||||
await receiveMessageLock.inLock(async () => {
|
await receiveMessageLock.inLock(async () => {
|
||||||
try {
|
try {
|
||||||
|
websocketEventsCounter.inc({ event_type: 'message' });
|
||||||
const { sid, message, localId } = data;
|
const { sid, message, localId } = data;
|
||||||
|
|
||||||
log({ module: 'websocket' }, `Received message from socket ${socket.id}: sessionId=${sid}, messageLength=${message.length} bytes, connectionType=${connection.connectionType}, connectionSessionId=${connection.connectionType === 'session-scoped' ? connection.sessionId : 'N/A'}`);
|
log({ module: 'websocket' }, `Received message from socket ${socket.id}: sessionId=${sid}, messageLength=${message.length} bytes, connectionType=${connection.connectionType}, connectionSessionId=${connection.connectionType === 'session-scoped' ? connection.sessionId : 'N/A'}`);
|
||||||
|
54
sources/app/metrics.ts
Normal file
54
sources/app/metrics.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import fastify from 'fastify';
|
||||||
|
import { db } from '@/storage/db';
|
||||||
|
import { register } from '@/modules/metrics';
|
||||||
|
import { log } from '@/utils/log';
|
||||||
|
|
||||||
|
export async function createMetricsServer() {
|
||||||
|
const app = fastify({
|
||||||
|
logger: false // Disable logging for metrics server
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/metrics', async (_request, reply) => {
|
||||||
|
try {
|
||||||
|
// Get Prisma metrics in Prometheus format
|
||||||
|
const prismaMetrics = await db.$metrics.prometheus();
|
||||||
|
|
||||||
|
// Get custom application metrics
|
||||||
|
const appMetrics = await register.metrics();
|
||||||
|
|
||||||
|
// Combine both metrics
|
||||||
|
const combinedMetrics = prismaMetrics + '\n' + appMetrics;
|
||||||
|
|
||||||
|
reply.type('text/plain; version=0.0.4; charset=utf-8');
|
||||||
|
reply.send(combinedMetrics);
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'metrics', level: 'error' }, `Error generating metrics: ${error}`);
|
||||||
|
reply.code(500).send('Internal Server Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/health', async (_request, reply) => {
|
||||||
|
reply.send({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startMetricsServer(): Promise<void> {
|
||||||
|
const enabled = process.env.METRICS_ENABLED !== 'false';
|
||||||
|
if (!enabled) {
|
||||||
|
log({ module: 'metrics' }, 'Metrics server disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = process.env.METRICS_PORT ? parseInt(process.env.METRICS_PORT, 10) : 9090;
|
||||||
|
const app = await createMetricsServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ port, host: '0.0.0.0' });
|
||||||
|
log({ module: 'metrics' }, `Metrics server listening on port ${port}`);
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'metrics', level: 'error' }, `Failed to start metrics server: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,8 @@ import { awaitShutdown, onShutdown } from "@/utils/shutdown";
|
|||||||
import { db } from './storage/db';
|
import { db } from './storage/db';
|
||||||
import { startTimeout } from "./app/timeout";
|
import { startTimeout } from "./app/timeout";
|
||||||
import { redis } from "./services/redis";
|
import { redis } from "./services/redis";
|
||||||
|
import { startMetricsServer } from "@/app/metrics";
|
||||||
|
import { activityCache } from "@/modules/sessionCache";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|
||||||
@ -12,6 +14,9 @@ async function main() {
|
|||||||
onShutdown('db', async () => {
|
onShutdown('db', async () => {
|
||||||
await db.$disconnect();
|
await db.$disconnect();
|
||||||
});
|
});
|
||||||
|
onShutdown('activity-cache', async () => {
|
||||||
|
activityCache.shutdown();
|
||||||
|
});
|
||||||
await redis.ping();
|
await redis.ping();
|
||||||
|
|
||||||
//
|
//
|
||||||
@ -19,6 +24,7 @@ async function main() {
|
|||||||
//
|
//
|
||||||
|
|
||||||
await startApi();
|
await startApi();
|
||||||
|
await startMetricsServer();
|
||||||
startTimeout();
|
startTimeout();
|
||||||
|
|
||||||
//
|
//
|
||||||
|
77
sources/modules/metrics.ts
Normal file
77
sources/modules/metrics.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { register, Counter, Gauge, Histogram } from 'prom-client';
|
||||||
|
|
||||||
|
// Application metrics
|
||||||
|
export const websocketConnectionsGauge = new Gauge({
|
||||||
|
name: 'websocket_connections_total',
|
||||||
|
help: 'Number of active WebSocket connections',
|
||||||
|
labelNames: ['type'] as const,
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessionAliveEventsCounter = new Counter({
|
||||||
|
name: 'session_alive_events_total',
|
||||||
|
help: 'Total number of session-alive events',
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
export const machineAliveEventsCounter = new Counter({
|
||||||
|
name: 'machine_alive_events_total',
|
||||||
|
help: 'Total number of machine-alive events',
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessionCacheCounter = new Counter({
|
||||||
|
name: 'session_cache_operations_total',
|
||||||
|
help: 'Total session cache operations',
|
||||||
|
labelNames: ['operation', 'result'] as const,
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
export const databaseUpdatesSkippedCounter = new Counter({
|
||||||
|
name: 'database_updates_skipped_total',
|
||||||
|
help: 'Number of database updates skipped due to debouncing',
|
||||||
|
labelNames: ['type'] as const,
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
export const websocketEventsCounter = new Counter({
|
||||||
|
name: 'websocket_events_total',
|
||||||
|
help: 'Total WebSocket events received by type',
|
||||||
|
labelNames: ['event_type'] as const,
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
export const httpRequestsCounter = new Counter({
|
||||||
|
name: 'http_requests_total',
|
||||||
|
help: 'Total number of HTTP requests',
|
||||||
|
labelNames: ['method', 'route', 'status'] as const,
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
export const httpRequestDurationHistogram = new Histogram({
|
||||||
|
name: 'http_request_duration_seconds',
|
||||||
|
help: 'HTTP request duration in seconds',
|
||||||
|
labelNames: ['method', 'route', 'status'] as const,
|
||||||
|
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket connection tracking
|
||||||
|
const connectionCounts = {
|
||||||
|
'user-scoped': 0,
|
||||||
|
'session-scoped': 0,
|
||||||
|
'machine-scoped': 0
|
||||||
|
};
|
||||||
|
|
||||||
|
export function incrementWebSocketConnection(type: 'user-scoped' | 'session-scoped' | 'machine-scoped'): void {
|
||||||
|
connectionCounts[type]++;
|
||||||
|
websocketConnectionsGauge.set({ type }, connectionCounts[type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrementWebSocketConnection(type: 'user-scoped' | 'session-scoped' | 'machine-scoped'): void {
|
||||||
|
connectionCounts[type] = Math.max(0, connectionCounts[type] - 1);
|
||||||
|
websocketConnectionsGauge.set({ type }, connectionCounts[type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the register for combining metrics
|
||||||
|
export { register };
|
260
sources/modules/sessionCache.ts
Normal file
260
sources/modules/sessionCache.ts
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import { db } from "@/storage/db";
|
||||||
|
import { log } from "@/utils/log";
|
||||||
|
import { sessionCacheCounter, databaseUpdatesSkippedCounter } from "@/modules/metrics";
|
||||||
|
|
||||||
|
interface SessionCacheEntry {
|
||||||
|
validUntil: number;
|
||||||
|
lastUpdateSent: number;
|
||||||
|
pendingUpdate: number | null;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MachineCacheEntry {
|
||||||
|
validUntil: number;
|
||||||
|
lastUpdateSent: number;
|
||||||
|
pendingUpdate: number | null;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ActivityCache {
|
||||||
|
private sessionCache = new Map<string, SessionCacheEntry>();
|
||||||
|
private machineCache = new Map<string, MachineCacheEntry>();
|
||||||
|
private batchTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// Cache TTL (30 seconds)
|
||||||
|
private readonly CACHE_TTL = 30 * 1000;
|
||||||
|
|
||||||
|
// Only update DB if time difference is significant (30 seconds)
|
||||||
|
private readonly UPDATE_THRESHOLD = 30 * 1000;
|
||||||
|
|
||||||
|
// Batch update interval (5 seconds)
|
||||||
|
private readonly BATCH_INTERVAL = 5 * 1000;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.startBatchTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startBatchTimer(): void {
|
||||||
|
if (this.batchTimer) {
|
||||||
|
clearInterval(this.batchTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.batchTimer = setInterval(() => {
|
||||||
|
this.flushPendingUpdates().catch(error => {
|
||||||
|
log({ module: 'session-cache', level: 'error' }, `Error flushing updates: ${error}`);
|
||||||
|
});
|
||||||
|
}, this.BATCH_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isSessionValid(sessionId: string, userId: string): Promise<boolean> {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = this.sessionCache.get(sessionId);
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (cached && cached.validUntil > now && cached.userId === userId) {
|
||||||
|
sessionCacheCounter.inc({ operation: 'session_validation', result: 'hit' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCacheCounter.inc({ operation: 'session_validation', result: 'miss' });
|
||||||
|
|
||||||
|
// Cache miss - check database
|
||||||
|
try {
|
||||||
|
const session = await db.session.findUnique({
|
||||||
|
where: { id: sessionId, accountId: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
// Cache the result
|
||||||
|
this.sessionCache.set(sessionId, {
|
||||||
|
validUntil: now + this.CACHE_TTL,
|
||||||
|
lastUpdateSent: session.lastActiveAt.getTime(),
|
||||||
|
pendingUpdate: null,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'session-cache', level: 'error' }, `Error validating session ${sessionId}: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isMachineValid(machineId: string, userId: string): Promise<boolean> {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = this.machineCache.get(machineId);
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (cached && cached.validUntil > now && cached.userId === userId) {
|
||||||
|
sessionCacheCounter.inc({ operation: 'machine_validation', result: 'hit' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCacheCounter.inc({ operation: 'machine_validation', result: 'miss' });
|
||||||
|
|
||||||
|
// Cache miss - check database
|
||||||
|
try {
|
||||||
|
const machine = await db.machine.findUnique({
|
||||||
|
where: {
|
||||||
|
accountId_id: {
|
||||||
|
accountId: userId,
|
||||||
|
id: machineId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (machine) {
|
||||||
|
// Cache the result
|
||||||
|
this.machineCache.set(machineId, {
|
||||||
|
validUntil: now + this.CACHE_TTL,
|
||||||
|
lastUpdateSent: machine.lastActiveAt?.getTime() || 0,
|
||||||
|
pendingUpdate: null,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'session-cache', level: 'error' }, `Error validating machine ${machineId}: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queueSessionUpdate(sessionId: string, timestamp: number): boolean {
|
||||||
|
const cached = this.sessionCache.get(sessionId);
|
||||||
|
if (!cached) {
|
||||||
|
return false; // Should validate first
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only queue if time difference is significant
|
||||||
|
const timeDiff = Math.abs(timestamp - cached.lastUpdateSent);
|
||||||
|
if (timeDiff > this.UPDATE_THRESHOLD) {
|
||||||
|
cached.pendingUpdate = timestamp;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseUpdatesSkippedCounter.inc({ type: 'session' });
|
||||||
|
return false; // No update needed
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMachineUpdate(machineId: string, timestamp: number): boolean {
|
||||||
|
const cached = this.machineCache.get(machineId);
|
||||||
|
if (!cached) {
|
||||||
|
return false; // Should validate first
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only queue if time difference is significant
|
||||||
|
const timeDiff = Math.abs(timestamp - cached.lastUpdateSent);
|
||||||
|
if (timeDiff > this.UPDATE_THRESHOLD) {
|
||||||
|
cached.pendingUpdate = timestamp;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseUpdatesSkippedCounter.inc({ type: 'machine' });
|
||||||
|
return false; // No update needed
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushPendingUpdates(): Promise<void> {
|
||||||
|
const sessionUpdates: { id: string, timestamp: number }[] = [];
|
||||||
|
const machineUpdates: { id: string, timestamp: number, userId: string }[] = [];
|
||||||
|
|
||||||
|
// Collect session updates
|
||||||
|
for (const [sessionId, entry] of this.sessionCache.entries()) {
|
||||||
|
if (entry.pendingUpdate) {
|
||||||
|
sessionUpdates.push({ id: sessionId, timestamp: entry.pendingUpdate });
|
||||||
|
entry.lastUpdateSent = entry.pendingUpdate;
|
||||||
|
entry.pendingUpdate = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect machine updates
|
||||||
|
for (const [machineId, entry] of this.machineCache.entries()) {
|
||||||
|
if (entry.pendingUpdate) {
|
||||||
|
machineUpdates.push({
|
||||||
|
id: machineId,
|
||||||
|
timestamp: entry.pendingUpdate,
|
||||||
|
userId: entry.userId
|
||||||
|
});
|
||||||
|
entry.lastUpdateSent = entry.pendingUpdate;
|
||||||
|
entry.pendingUpdate = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch update sessions
|
||||||
|
if (sessionUpdates.length > 0) {
|
||||||
|
try {
|
||||||
|
await Promise.all(sessionUpdates.map(update =>
|
||||||
|
db.session.update({
|
||||||
|
where: { id: update.id },
|
||||||
|
data: { lastActiveAt: new Date(update.timestamp), active: true }
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
log({ module: 'session-cache' }, `Flushed ${sessionUpdates.length} session updates`);
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'session-cache', level: 'error' }, `Error updating sessions: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch update machines
|
||||||
|
if (machineUpdates.length > 0) {
|
||||||
|
try {
|
||||||
|
await Promise.all(machineUpdates.map(update =>
|
||||||
|
db.machine.update({
|
||||||
|
where: {
|
||||||
|
accountId_id: {
|
||||||
|
accountId: update.userId,
|
||||||
|
id: update.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: { lastActiveAt: new Date(update.timestamp) }
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
log({ module: 'session-cache' }, `Flushed ${machineUpdates.length} machine updates`);
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'session-cache', level: 'error' }, `Error updating machines: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup old cache entries periodically
|
||||||
|
cleanup(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [sessionId, entry] of this.sessionCache.entries()) {
|
||||||
|
if (entry.validUntil < now) {
|
||||||
|
this.sessionCache.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [machineId, entry] of this.machineCache.entries()) {
|
||||||
|
if (entry.validUntil < now) {
|
||||||
|
this.machineCache.delete(machineId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown(): void {
|
||||||
|
if (this.batchTimer) {
|
||||||
|
clearInterval(this.batchTimer);
|
||||||
|
this.batchTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush any remaining updates
|
||||||
|
this.flushPendingUpdates().catch(error => {
|
||||||
|
log({ module: 'session-cache', level: 'error' }, `Error flushing final updates: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instance
|
||||||
|
export const activityCache = new ActivityCache();
|
||||||
|
|
||||||
|
// Cleanup every 5 minutes
|
||||||
|
setInterval(() => {
|
||||||
|
activityCache.cleanup();
|
||||||
|
}, 5 * 60 * 1000);
|
25
yarn.lock
25
yarn.lock
@ -381,6 +381,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a"
|
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a"
|
||||||
integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==
|
integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==
|
||||||
|
|
||||||
|
"@opentelemetry/api@^1.4.0":
|
||||||
|
version "1.9.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe"
|
||||||
|
integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==
|
||||||
|
|
||||||
"@peculiar/asn1-cms@^2.3.13", "@peculiar/asn1-cms@^2.3.15":
|
"@peculiar/asn1-cms@^2.3.13", "@peculiar/asn1-cms@^2.3.15":
|
||||||
version "2.3.15"
|
version "2.3.15"
|
||||||
resolved "https://registry.yarnpkg.com/@peculiar/asn1-cms/-/asn1-cms-2.3.15.tgz#8baf1fcf51dae2e9122126e13acf6a2e1698d35c"
|
resolved "https://registry.yarnpkg.com/@peculiar/asn1-cms/-/asn1-cms-2.3.15.tgz#8baf1fcf51dae2e9122126e13acf6a2e1698d35c"
|
||||||
@ -1024,6 +1029,11 @@ base64id@2.0.0, base64id@~2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
|
resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
|
||||||
integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
|
integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
|
||||||
|
|
||||||
|
bintrees@1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8"
|
||||||
|
integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==
|
||||||
|
|
||||||
buffer-equal-constant-time@^1.0.1:
|
buffer-equal-constant-time@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
|
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
|
||||||
@ -2037,6 +2047,14 @@ process@^0.11.10:
|
|||||||
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
|
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
|
||||||
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
|
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
|
||||||
|
|
||||||
|
prom-client@^15.1.3:
|
||||||
|
version "15.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-15.1.3.tgz#69fa8de93a88bc9783173db5f758dc1c69fa8fc2"
|
||||||
|
integrity sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==
|
||||||
|
dependencies:
|
||||||
|
"@opentelemetry/api" "^1.4.0"
|
||||||
|
tdigest "^0.1.1"
|
||||||
|
|
||||||
proxy-from-env@^1.1.0:
|
proxy-from-env@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
|
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
|
||||||
@ -2352,6 +2370,13 @@ supports-color@^7.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-flag "^4.0.0"
|
has-flag "^4.0.0"
|
||||||
|
|
||||||
|
tdigest@^0.1.1:
|
||||||
|
version "0.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced"
|
||||||
|
integrity sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==
|
||||||
|
dependencies:
|
||||||
|
bintrees "1.0.2"
|
||||||
|
|
||||||
thread-stream@^3.0.0:
|
thread-stream@^3.0.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-3.1.0.tgz#4b2ef252a7c215064507d4ef70c05a5e2d34c4f1"
|
resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-3.1.0.tgz#4b2ef252a7c215064507d4ef70c05a5e2d34c4f1"
|
||||||
|
Loading…
Reference in New Issue
Block a user