Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 58 additions & 41 deletions frontend/app/api/sessions/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import { NextResponse } from 'next/server';

import { db } from '@/db';
import { activeSessions } from '@/db/schema/sessions';
import { getRedisClient } from '@/lib/redis';

const SESSION_TIMEOUT_MINUTES = 15;
const SESSION_TIMEOUT_MS = SESSION_TIMEOUT_MINUTES * 60 * 1000;
const REDIS_KEY = 'online_sessions';

function getHeartbeatThrottleMs(): number {
const raw = process.env.HEARTBEAT_THROTTLE_MS;
Expand All @@ -17,56 +20,70 @@ function getHeartbeatThrottleMs(): number {
return Math.max(floor, parsed);
}

export async function POST() {
async function heartbeatViaRedis(sessionId: string): Promise<number | null> {
const redis = getRedisClient();
if (!redis) return null;

try {
const now = Date.now();
const pipeline = redis.pipeline();
pipeline.zadd(REDIS_KEY, { score: now, member: sessionId });
pipeline.zremrangebyscore(REDIS_KEY, '-inf', now - SESSION_TIMEOUT_MS);
pipeline.zcard(REDIS_KEY);

const results = await pipeline.exec();
return results[2] as number;
} catch (err) {
console.warn('Redis heartbeat failed, falling back to DB:', err);
return null;
}
}

async function heartbeatViaDb(sessionId: string): Promise<number> {
const now = new Date();
const heartbeatThreshold = new Date(
now.getTime() - getHeartbeatThrottleMs()
);

await db
.insert(activeSessions)
.values({ sessionId, lastActivity: now })
.onConflictDoUpdate({
target: activeSessions.sessionId,
set: { lastActivity: now },
setWhere: lt(activeSessions.lastActivity, heartbeatThreshold),
});

if (Math.random() < 0.05) {
const cleanupThreshold = new Date(Date.now() - SESSION_TIMEOUT_MS);
db.delete(activeSessions)
.where(lt(activeSessions.lastActivity, cleanupThreshold))
.catch(err => console.error('Cleanup error:', err));
}

const countThreshold = new Date(Date.now() - SESSION_TIMEOUT_MS);
const result = await db
.select({ total: sql<number>`count(*)` })
.from(activeSessions)
.where(gte(activeSessions.lastActivity, countThreshold));

return Number(result[0]?.total || 0);
}


export async function POST() {
try {
const cookieStore = await cookies();
let sessionId = cookieStore.get('user_session_id')?.value;

if (!sessionId) {
sessionId = randomUUID();
}

const now = new Date();
const heartbeatThreshold = new Date(
now.getTime() - getHeartbeatThrottleMs()
);

await db
.insert(activeSessions)
.values({
sessionId,
lastActivity: now,
})
.onConflictDoUpdate({
target: activeSessions.sessionId,
set: { lastActivity: now },
setWhere: lt(activeSessions.lastActivity, heartbeatThreshold),
});

if (Math.random() < 0.05) {
const cleanupThreshold = new Date(
Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000
);

db.delete(activeSessions)
.where(lt(activeSessions.lastActivity, cleanupThreshold))
.catch(err => console.error('Cleanup error:', err));
}

const countThreshold = new Date(
Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000
);
const redisCount = await heartbeatViaRedis(sessionId);
const online = redisCount ?? (await heartbeatViaDb(sessionId));
Comment on lines +83 to +84
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid switching back to Redis count immediately after an outage.

Sessions written via heartbeatViaDb() exist only in active_sessions, so redisCount ?? ... undercounts as soon as Redis starts responding again. After a transient outage, the online number can collapse until every still-active user heartbeats back into Redis. Consider keeping DB as the source of truth for one timeout window after a Redis failure, or seeding Redis from recent active_sessions rows before switching back.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/sessions/activity/route.ts` around lines 83 - 84, The
current logic flips back to Redis immediately when heartbeatViaRedis(sessionId)
returns a value, causing undercount after transient Redis outages; change the
logic so that when heartbeatViaRedis(sessionId) returns null/undefined
(indicating an outage) you record the outage time and use
heartbeatViaDb(sessionId) as the source of truth for at least one timeout window
(e.g., SESSION_TIMEOUT_MS) after that outage, or alternatively seed Redis from
recent active_sessions before switching; update the code around redisCount,
online, and the functions heartbeatViaRedis/heartbeatViaDb to check a
lastRedisOutage timestamp (or a boolean flag) and continue using DB results
until the grace period expires, then resume using Redis normally.


const result = await db
.select({
total: sql<number>`count(*)`,
})
.from(activeSessions)
.where(gte(activeSessions.lastActivity, countThreshold));

const response = NextResponse.json({
online: Number(result[0]?.total || 0),
});
const response = NextResponse.json({ online });

response.cookies.set('user_session_id', sessionId, {
httpOnly: true,
Expand Down
2 changes: 1 addition & 1 deletion frontend/lib/about/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const getPlatformStats = unstable_cache(

const linkedinCount = process.env.LINKEDIN_FOLLOWER_COUNT
? parseInt(process.env.LINKEDIN_FOLLOWER_COUNT)
: 1700;
: 1800;

let totalUsers = 243;
let solvedTests = 1890;
Expand Down
Loading