fix(dashboard): cache org stats in Redis with versioned invalidation (#883)
* fix(dashboard): cache org stats in Redis with versioned invalidation Replace the never-expiring process-local cache in the org dashboard page load with API-side Redis caching keyed per org. A version stamp prevents slow reads from writing pre-purge data back into the cache. Invalidate on course create/update/delete, student enroll/remove, certificate claim, and progress reset. Remove the dashboard SSR module cache so every page load reads through the API cache layer. Fixes #852 Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): address PR review on stats cache invalidation - Validate bust via ZDashStats instead of raw query access - Use import type for OrganisationAnalytics - Resolve orgId before course delete so invalidation is reliable - Invalidate immediately after persisted writes, before fallible side effects - Invalidate on member role changes and program course access enrollment Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): complete stats cache invalidation coverage Wire org stats cache purge on lesson completion (manual toggle and video-watch auto-complete) so topCourses completion percentages stay fresh. Harden bulk member adds with sequential inserts and a finally invalidation boundary, and only purge org invite enrollments when new group memberships were actually inserted. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(core): make lesson completion stats purge best-effort Wrap org stats cache invalidation in try/catch so a lookup or Redis failure cannot turn a successful lesson completion into a 500. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): keep org stats cache through lesson completion Lesson complete is a high-frequency event and usually does not change the rounded dashboard percentages. Rely on a 10 minute TTL instead of purging the snapshot on every completion. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * docs: explain how admin dashboard numbers are calculated Add a user-facing guide covering headline counts, top courses, certificates, login activity, and when the 10 minute snapshot refreshes. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> ---------
This commit is contained in:
co-authored by
Rotimi Best
parent
9fb75e11d2
commit
2ebb544a81
@@ -8,6 +8,7 @@ import {
|
||||
getCourseProgress as getCourseProgressQuery,
|
||||
getCourseById,
|
||||
getCourseWithRelations,
|
||||
getOrgIdByCourseId,
|
||||
updateCourse as updateCourseQuery,
|
||||
updateLessonsSectionId
|
||||
} from '@cio/db/queries/course';
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getStudentLimit } from '@cio/utils/plans';
|
||||
import { env } from '../../config/env';
|
||||
import { invalidateOrgStats } from '../../utils/redis/org-stats-cache';
|
||||
import { annotateCourseContentWithProgression } from './progression';
|
||||
import { buildCourseContent, calcPercentageWithRounding, type CourseContent } from './utils';
|
||||
import { guardCourseTypeTransition } from './public-course-guard';
|
||||
@@ -119,6 +121,10 @@ export async function ensureProgramCourseAccess(courseId: string, profileId: str
|
||||
);
|
||||
});
|
||||
|
||||
if (access.roleId === ROLE.STUDENT) {
|
||||
await invalidateOrgStats(organizationId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -307,6 +313,8 @@ export async function createCourse(
|
||||
};
|
||||
});
|
||||
|
||||
await invalidateOrgStats(data.organizationId);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -420,6 +428,11 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
const statsOrgId = await getOrgIdByCourseId(courseId);
|
||||
await invalidateOrgStats(statsOrgId);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -440,10 +453,14 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
|
||||
*/
|
||||
export async function deleteCourse(courseId: string) {
|
||||
try {
|
||||
const statsOrgId = await getOrgIdByCourseId(courseId);
|
||||
const deleted = await deleteCourseQuery(courseId);
|
||||
if (!deleted) {
|
||||
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
await invalidateOrgStats(statsOrgId);
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { env } from '../../config/env';
|
||||
import { getOrgIdsByGroupIds } from '@cio/db/queries/group';
|
||||
import { logRedisUnavailableOnce, redis } from './redis';
|
||||
|
||||
/** TTL for org dashboard stats payload (10 min). */
|
||||
export const ORG_STATS_CACHE_TTL_SECONDS = 600;
|
||||
|
||||
export function orgStatsKey(orgId: string): string {
|
||||
return `dash:stats:v1:${orgId}`;
|
||||
}
|
||||
|
||||
export function orgStatsVersionKey(orgId: string): string {
|
||||
return `dash:stats:ver:${orgId}`;
|
||||
}
|
||||
|
||||
type CachedOrgStatsPayload<T> = {
|
||||
version: string;
|
||||
data: T;
|
||||
};
|
||||
|
||||
function parseCachedOrgStatsPayload<T>(raw: string): CachedOrgStatsPayload<T> | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
!('version' in parsed) ||
|
||||
!('data' in parsed) ||
|
||||
typeof (parsed as { version: unknown }).version !== 'string' ||
|
||||
typeof (parsed as { data: unknown }).data !== 'object' ||
|
||||
(parsed as { data: unknown }).data === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as CachedOrgStatsPayload<T>;
|
||||
}
|
||||
|
||||
export async function readOrgStatsVersionAndCache<T>(orgId: string): Promise<{ version: string; data: T | null }> {
|
||||
if (!env.REDIS_URL) {
|
||||
return { version: '0', data: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const versionKey = orgStatsVersionKey(orgId);
|
||||
const payloadKey = orgStatsKey(orgId);
|
||||
const [versionRaw, payloadRaw] = await redis.mGet([versionKey, payloadKey]);
|
||||
const version = versionRaw ?? '0';
|
||||
|
||||
if (!payloadRaw) {
|
||||
return { version, data: null };
|
||||
}
|
||||
|
||||
const payload = parseCachedOrgStatsPayload<T>(payloadRaw);
|
||||
if (!payload || payload.version !== version) {
|
||||
return { version, data: null };
|
||||
}
|
||||
|
||||
return { version, data: payload.data };
|
||||
} catch (error) {
|
||||
logRedisUnavailableOnce('Redis get failed for org stats cache, using database', error);
|
||||
return { version: '0', data: null };
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeOrgStatsCache<T>(orgId: string, version: string, value: T): Promise<void> {
|
||||
if (!env.REDIS_URL) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: CachedOrgStatsPayload<T> = { version, data: value };
|
||||
await redis.setEx(orgStatsKey(orgId), ORG_STATS_CACHE_TTL_SECONDS, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
logRedisUnavailableOnce('Redis set failed for org stats cache, continuing', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateOrgStats(orgId: string | null | undefined): Promise<void> {
|
||||
if (!orgId || !env.REDIS_URL) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.incr(orgStatsVersionKey(orgId));
|
||||
await redis.del(orgStatsKey(orgId));
|
||||
} catch (error) {
|
||||
logRedisUnavailableOnce('Redis invalidate failed for org stats cache, continuing', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateOrgStatsForIds(orgIds: string[]): Promise<void> {
|
||||
const uniqueOrgIds = [...new Set(orgIds.filter((orgId) => Boolean(orgId)))];
|
||||
|
||||
for (const orgId of uniqueOrgIds) {
|
||||
await invalidateOrgStats(orgId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateOrgStatsForGroupIds(groupIds: string[]): Promise<void> {
|
||||
const orgIds = await getOrgIdsByGroupIds(groupIds);
|
||||
await invalidateOrgStatsForIds(orgIds);
|
||||
}
|
||||
@@ -1167,6 +1167,27 @@ export const getExploreCourses = async ({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the organization ID for a course via its group.
|
||||
*/
|
||||
export async function getOrgIdByCourseId(courseId: string): Promise<string | null> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ orgId: schema.group.organizationId })
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.limit(1);
|
||||
|
||||
return row?.orgId ?? null;
|
||||
} catch (error) {
|
||||
console.error('getOrgIdByCourseId error:', error);
|
||||
throw new Error(
|
||||
`Failed to get organization ID for course: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets course with organization data (title, org name, org siteName, groupId)
|
||||
* @param courseId Course ID
|
||||
|
||||
@@ -328,3 +328,23 @@ export async function insertGroupMembersOnConflictDoNothing(
|
||||
throw new Error(`Failed to insert group members: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrgIdsByGroupIds(groupIds: string[]): Promise<string[]> {
|
||||
if (groupIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.selectDistinct({ orgId: schema.group.organizationId })
|
||||
.from(schema.group)
|
||||
.where(inArray(schema.group.id, groupIds));
|
||||
|
||||
return rows.map((row) => row.orgId).filter((orgId): orgId is string => Boolean(orgId));
|
||||
} catch (error) {
|
||||
console.error('getOrgIdsByGroupIds error:', error);
|
||||
throw new Error(
|
||||
`Failed to get organization IDs for groups: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import * as z from 'zod';
|
||||
export const ZDashStats = z
|
||||
.object({
|
||||
orgId: z.string().optional(),
|
||||
siteName: z.string().optional()
|
||||
siteName: z.string().optional(),
|
||||
bust: z
|
||||
.enum(['0', '1'])
|
||||
.optional()
|
||||
.transform((value) => value === '1')
|
||||
})
|
||||
.refine((v) => !!(v.orgId || v.siteName), {
|
||||
message: 'Either orgId or siteName is required'
|
||||
|
||||
Reference in New Issue
Block a user