fix : reaible vistor count
This commit is contained in:
@@ -7,6 +7,7 @@ import { updateVisitorPresence } from "@/lib/presence";
|
|||||||
|
|
||||||
const inputSchema = z.strictObject({
|
const inputSchema = z.strictObject({
|
||||||
tabId: z.string().uuid(),
|
tabId: z.string().uuid(),
|
||||||
|
browserId: z.string().uuid(),
|
||||||
guideId: z.string().uuid().nullable(),
|
guideId: z.string().uuid().nullable(),
|
||||||
active: z.boolean().default(true),
|
active: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ function useHeartbeat<T>({
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tabId: identifyBy === "browser" ? visitorId() : tabId(),
|
tabId: tabId(),
|
||||||
|
...(identifyBy === "browser" ? { browserId: visitorId() } : {}),
|
||||||
guideId,
|
guideId,
|
||||||
...(section !== undefined ? { section } : {}),
|
...(section !== undefined ? { section } : {}),
|
||||||
active,
|
active,
|
||||||
@@ -82,7 +83,7 @@ function useHeartbeat<T>({
|
|||||||
if (document.visibilityState === "visible") void send();
|
if (document.visibilityState === "visible") void send();
|
||||||
};
|
};
|
||||||
const leave = () => {
|
const leave = () => {
|
||||||
if (identifyBy === "tab") void send(false);
|
void send(false);
|
||||||
};
|
};
|
||||||
window.addEventListener("focus", refresh);
|
window.addEventListener("focus", refresh);
|
||||||
window.addEventListener("pagehide", leave);
|
window.addEventListener("pagehide", leave);
|
||||||
|
|||||||
+29
-5
@@ -36,16 +36,19 @@ async function purge(
|
|||||||
|
|
||||||
export async function updateVisitorPresence({
|
export async function updateVisitorPresence({
|
||||||
tabId,
|
tabId,
|
||||||
|
browserId,
|
||||||
guideId,
|
guideId,
|
||||||
active,
|
active,
|
||||||
}: {
|
}: {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
|
browserId: string;
|
||||||
guideId: string | null;
|
guideId: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}) {
|
}) {
|
||||||
const redis = await getRedisClient();
|
const redis = await getRedisClient();
|
||||||
const activeKey = `${prefix()}:visitors`;
|
const activeKey = `${prefix()}:visitors`;
|
||||||
const scopeKey = `${prefix()}:visitor-scopes`;
|
const scopeKey = `${prefix()}:visitor-scopes`;
|
||||||
|
const browserKey = `${prefix()}:visitor-browsers`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const serialized = await redis.eval(`
|
const serialized = await redis.eval(`
|
||||||
local now = tonumber(ARGV[1])
|
local now = tonumber(ARGV[1])
|
||||||
@@ -53,31 +56,52 @@ export async function updateVisitorPresence({
|
|||||||
for _, id in ipairs(expired) do
|
for _, id in ipairs(expired) do
|
||||||
redis.call('ZREM', KEYS[1], id)
|
redis.call('ZREM', KEYS[1], id)
|
||||||
redis.call('HDEL', KEYS[2], id)
|
redis.call('HDEL', KEYS[2], id)
|
||||||
|
redis.call('HDEL', KEYS[3], id)
|
||||||
end
|
end
|
||||||
if ARGV[5] == '1' then
|
if ARGV[5] == '1' then
|
||||||
if redis.call('ZCARD', KEYS[1]) >= 10000 and not redis.call('ZSCORE', KEYS[1], ARGV[2]) then
|
if redis.call('ZCARD', KEYS[1]) >= 10000 and not redis.call('ZSCORE', KEYS[1], ARGV[2]) then
|
||||||
return redis.error_reply('presence capacity reached')
|
return redis.error_reply('presence capacity reached')
|
||||||
end
|
end
|
||||||
|
local previousScope = redis.call('HGET', KEYS[2], ARGV[2])
|
||||||
|
local previousBrowser = redis.call('HGET', KEYS[3], ARGV[2])
|
||||||
redis.call('ZADD', KEYS[1], now + tonumber(ARGV[4]), ARGV[2])
|
redis.call('ZADD', KEYS[1], now + tonumber(ARGV[4]), ARGV[2])
|
||||||
redis.call('HSET', KEYS[2], ARGV[2], ARGV[3])
|
redis.call('HSET', KEYS[2], ARGV[2], ARGV[3])
|
||||||
|
redis.call('HSET', KEYS[3], ARGV[2], ARGV[6])
|
||||||
|
if previousScope ~= ARGV[3] or previousBrowser ~= ARGV[6] then redis.call('DEL', KEYS[4]) end
|
||||||
else
|
else
|
||||||
redis.call('ZREM', KEYS[1], ARGV[2])
|
redis.call('ZREM', KEYS[1], ARGV[2])
|
||||||
redis.call('HDEL', KEYS[2], ARGV[2])
|
redis.call('HDEL', KEYS[2], ARGV[2])
|
||||||
|
redis.call('HDEL', KEYS[3], ARGV[2])
|
||||||
|
redis.call('DEL', KEYS[4])
|
||||||
end
|
end
|
||||||
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[4]) * 2)
|
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[4]) * 2)
|
||||||
redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[4]) * 2)
|
redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[4]) * 2)
|
||||||
local snapshot = redis.call('GET', KEYS[3])
|
redis.call('PEXPIRE', KEYS[3], tonumber(ARGV[4]) * 2)
|
||||||
|
if #expired > 0 then redis.call('DEL', KEYS[4]) end
|
||||||
|
local snapshot = redis.call('GET', KEYS[4])
|
||||||
if snapshot then return snapshot end
|
if snapshot then return snapshot end
|
||||||
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], now + 1, '+inf')
|
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], now + 1, '+inf')
|
||||||
local counts = {}
|
local counts = {}
|
||||||
|
local browsers = {}
|
||||||
|
local guideBrowsers = {}
|
||||||
for _, id in ipairs(ids) do
|
for _, id in ipairs(ids) do
|
||||||
|
local browser = redis.call('HGET', KEYS[3], id) or id
|
||||||
local scope = redis.call('HGET', KEYS[2], id)
|
local scope = redis.call('HGET', KEYS[2], id)
|
||||||
if scope and scope ~= '' then counts[scope] = (counts[scope] or 0) + 1 end
|
browsers[browser] = true
|
||||||
|
if scope and scope ~= '' then
|
||||||
|
local key = scope .. ':' .. browser
|
||||||
|
if not guideBrowsers[key] then
|
||||||
|
counts[scope] = (counts[scope] or 0) + 1
|
||||||
|
guideBrowsers[key] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
snapshot = cjson.encode({global = #ids, byGuide = counts})
|
local global = 0
|
||||||
redis.call('SET', KEYS[3], snapshot, 'PX', 5000)
|
for _ in pairs(browsers) do global = global + 1 end
|
||||||
|
snapshot = cjson.encode({global = global, byGuide = counts})
|
||||||
|
redis.call('SET', KEYS[4], snapshot, 'PX', 5000)
|
||||||
return snapshot
|
return snapshot
|
||||||
`, 3, activeKey, scopeKey, `${prefix()}:visitor-counts`, now, tabId, guideId ?? "", PRESENCE_TTL_MS, active ? "1" : "0") as string;
|
`, 4, activeKey, scopeKey, browserKey, `${prefix()}:visitor-counts`, now, tabId, guideId ?? "", PRESENCE_TTL_MS, active ? "1" : "0", browserId) as string;
|
||||||
const snapshot = JSON.parse(serialized) as { global: number; byGuide: Record<string, number> };
|
const snapshot = JSON.parse(serialized) as { global: number; byGuide: Record<string, number> };
|
||||||
return { ...snapshot, guide: guideId ? snapshot.byGuide[guideId] ?? 0 : null };
|
return { ...snapshot, guide: guideId ? snapshot.byGuide[guideId] ?? 0 : null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,14 +54,33 @@ describeWithRedis("shared Redis security paths", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("tracks visitor counts without a separate Redis round trip per visitor", async () => {
|
it("tracks visitor counts without a separate Redis round trip per visitor", async () => {
|
||||||
const first = await updateVisitorPresence({ tabId: `${runId}:1`, guideId: "guide-a", active: true });
|
const first = await updateVisitorPresence({ tabId: `${runId}:1`, browserId: `${runId}:browser-1`, guideId: "guide-a", active: true });
|
||||||
expect(first.global).toBe(1);
|
expect(first.global).toBe(1);
|
||||||
expect(first.guide).toBe(1);
|
expect(first.guide).toBe(1);
|
||||||
await updateVisitorPresence({ tabId: `${runId}:2`, guideId: "guide-a", active: true });
|
const second = await updateVisitorPresence({ tabId: `${runId}:2`, browserId: `${runId}:browser-2`, guideId: "guide-a", active: true });
|
||||||
|
expect(second.global).toBe(2);
|
||||||
|
expect(second.guide).toBe(2);
|
||||||
const count = await publisher.zcard(`${process.env.REDIS_PRESENCE_PREFIX}:visitors`);
|
const count = await publisher.zcard(`${process.env.REDIS_PRESENCE_PREFIX}:visitors`);
|
||||||
expect(count).toBe(2);
|
expect(count).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts a browser once across tabs without losing either guide", async () => {
|
||||||
|
const browserId = `${runId}:browser-3`;
|
||||||
|
const firstTab = `${runId}:3`;
|
||||||
|
const secondTab = `${runId}:4`;
|
||||||
|
const firstGuide = await updateVisitorPresence({ tabId: firstTab, browserId, guideId: "guide-c", active: true });
|
||||||
|
const secondGuide = await updateVisitorPresence({ tabId: secondTab, browserId, guideId: "guide-d", active: true });
|
||||||
|
expect(secondGuide.global).toBe(firstGuide.global);
|
||||||
|
expect(secondGuide.guide).toBe(1);
|
||||||
|
expect(secondGuide.byGuide["guide-c"]).toBe(1);
|
||||||
|
const sameGuide = await updateVisitorPresence({ tabId: secondTab, browserId, guideId: "guide-c", active: true });
|
||||||
|
expect(sameGuide.guide).toBe(1);
|
||||||
|
expect(sameGuide.byGuide["guide-d"]).toBeUndefined();
|
||||||
|
const afterLeave = await updateVisitorPresence({ tabId: secondTab, browserId, guideId: "guide-d", active: false });
|
||||||
|
expect(afterLeave.byGuide["guide-c"]).toBe(1);
|
||||||
|
expect(afterLeave.byGuide["guide-d"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("fans one subscribed topic out to two SSE clients", async () => {
|
it("fans one subscribed topic out to two SSE clients", async () => {
|
||||||
const topic = `page:${runId}`;
|
const topic = `page:${runId}`;
|
||||||
const first = new AbortController();
|
const first = new AbortController();
|
||||||
|
|||||||
Reference in New Issue
Block a user