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
3 changes: 2 additions & 1 deletion apps/backend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { DatabaseModule } from './database/database.module';
import { AdminModule } from './admin/admin.module';
import { JobQueueModule } from './job-queue/job-queue.module';
import { HealthModule } from './health/health.module';
import { CacheModule } from './cache/cache.module';

@Module({
imports: [ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env.dev',
cache: true
}), AuthModule, DatabaseModule, AdminModule, HttpModule, JobQueueModule, HealthModule],
}), AuthModule, DatabaseModule, AdminModule, HttpModule, JobQueueModule, HealthModule, CacheModule],
controllers: [AppController]
})
export class AppModule {}
99 changes: 99 additions & 0 deletions apps/backend/src/app/cache/booklet-cache-scheduler.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CacheService } from './cache.service';
import Persons from '../database/entities/persons.entity';
import { WorkspaceTestResultsService } from '../database/services/workspace-test-results.service';

@Injectable()
export class BookletCacheSchedulerService {
private readonly logger = new Logger(BookletCacheSchedulerService.name);
private readonly BOOKLET_CACHE_TTL = 24 * 60 * 60; // 24 hours in seconds

constructor(
private readonly cacheService: CacheService,
private readonly workspaceTestResultsService: WorkspaceTestResultsService,
@InjectRepository(Persons)
private readonly personsRepository: Repository<Persons>
) {}

/**
* Scheduled task to cache all test person booklets
* Runs every night at 3:00 AM (after the response cache scheduler)
*/
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async cacheAllBooklets() {
this.logger.log('Starting nightly task to cache all test person booklets');

try {
// Get all workspaces with persons
const workspaces = await this.getWorkspacesWithPersons();

for (const workspace of workspaces) {
const workspaceId = workspace.workspace_id;
this.logger.log(`Caching booklets for workspace ${workspaceId}`);

// Get all test persons in this workspace
const persons = await this.personsRepository.find({
where: { workspace_id: workspaceId, consider: true }
});

for (const person of persons) {
try {
// Cache the booklet data for this person
await this.cachePersonBooklets(person.id, workspaceId);
} catch (error) {
this.logger.error(`Error caching booklets for person ID ${person.id} in workspace ${workspaceId}: ${error.message}`, error.stack);
}
}
}

this.logger.log('Finished nightly caching of all test person booklets');
} catch (error) {
this.logger.error(`Error in cacheAllBooklets: ${error.message}`, error.stack);
}
}

/**
* Get all workspaces that have persons
*/
private async getWorkspacesWithPersons(): Promise<{ workspace_id: number }[]> {
return this.personsRepository
.createQueryBuilder('person')
.select('DISTINCT person.workspace_id', 'workspace_id')
.where('person.consider = :consider', { consider: true })
.getRawMany();
}

/**
* Cache booklet data for a specific person
*/
private async cachePersonBooklets(personId: number, workspaceId: number): Promise<void> {
const cacheKey = this.generateBookletCacheKey(workspaceId, personId);

// Check if already in cache
const exists = await this.cacheService.exists(cacheKey);
if (exists) {
this.logger.debug(`Booklet data already in cache for person ID ${personId} in workspace ${workspaceId}`);
return;
}

// Fetch and cache the booklet data
try {
const bookletData = await this.workspaceTestResultsService.findPersonTestResults(personId, workspaceId);
await this.cacheService.set(cacheKey, bookletData, this.BOOKLET_CACHE_TTL);
this.logger.debug(`Cached booklet data for person ID ${personId} in workspace ${workspaceId}`);
} catch (error) {
this.logger.error(`Error fetching booklet data for caching: ${error.message}`, error.stack);
throw error;
}
}

/**
* Generate a cache key for booklet data
*/
private generateBookletCacheKey(workspaceId: number, personId: number): string {
return `booklets:${workspaceId}:${personId}`;
}
}
35 changes: 35 additions & 0 deletions apps/backend/src/app/cache/cache.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Module, forwardRef } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RedisModule } from '@nestjs-modules/ioredis';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CacheService } from './cache.service';
import { ResponseCacheSchedulerService } from './response-cache-scheduler.service';
import { BookletCacheSchedulerService } from './booklet-cache-scheduler.service';
import Persons from '../database/entities/persons.entity';
import { Unit } from '../database/entities/unit.entity';
// eslint-disable-next-line import/no-cycle
import { DatabaseModule } from '../database/database.module';

@Module({
imports: [
RedisModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'single',
options: {
host: configService.get('REDIS_HOST', 'redis'),
port: parseInt(configService.get('REDIS_PORT', '6379'), 10),
keyPrefix: `${configService.get('REDIS_PREFIX', 'coding-box')}:cache:`
}
})
}),
ScheduleModule.forRoot(),
TypeOrmModule.forFeature([Persons, Unit]),
forwardRef(() => DatabaseModule)
],
providers: [CacheService, ResponseCacheSchedulerService, BookletCacheSchedulerService],
exports: [CacheService]
})
export class CacheModule {}
90 changes: 90 additions & 0 deletions apps/backend/src/app/cache/cache.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';

@Injectable()
export class CacheService {
private readonly logger = new Logger(CacheService.name);
private readonly DEFAULT_TTL = 3600; // 1 hour in seconds

constructor(
@InjectRedis() private readonly redis: Redis
) {}

/**
* Get a value from the cache
* @param key The cache key
* @returns The cached value or null if not found
*/
async get<T>(key: string): Promise<T | null> {
try {
const cachedValue = await this.redis.get(key);
if (!cachedValue) {
return null;
}
return JSON.parse(cachedValue) as T;
} catch (error) {
this.logger.error(`Error getting value from cache: ${error.message}`, error.stack);
return null;
}
}

/**
* Set a value in the cache
* @param key The cache key
* @param value The value to cache
* @param ttl Time to live in seconds (optional, defaults to 1 hour)
* @returns True if the value was set, false otherwise
*/
async set<T>(key: string, value: T, ttl: number = this.DEFAULT_TTL): Promise<boolean> {
try {
const serializedValue = JSON.stringify(value);
await this.redis.set(key, serializedValue, 'EX', ttl);
return true;
} catch (error) {
this.logger.error(`Error setting value in cache: ${error.message}`, error.stack);
return false;
}
}

/**
* Delete a value from the cache
* @param key The cache key
* @returns True if the value was deleted, false otherwise
*/
async delete(key: string): Promise<boolean> {
try {
await this.redis.del(key);
return true;
} catch (error) {
this.logger.error(`Error deleting value from cache: ${error.message}`, error.stack);
return false;
}
}

/**
* Check if a key exists in the cache
* @param key The cache key
* @returns True if the key exists, false otherwise
*/
async exists(key: string): Promise<boolean> {
try {
const exists = await this.redis.exists(key);
return exists === 1;
} catch (error) {
this.logger.error(`Error checking if key exists in cache: ${error.message}`, error.stack);
return false;
}
}

/**
* Generate a cache key for unit responses
* @param workspaceId The workspace ID
* @param testPerson The test person ID
* @param unitId The unit ID
* @returns The cache key
*/
generateUnitResponseCacheKey(workspaceId: number, testPerson: string, unitId: string): string {
return `responses:${workspaceId}:${testPerson}:${unitId}`;
}
}
121 changes: 121 additions & 0 deletions apps/backend/src/app/cache/response-cache-scheduler.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CacheService } from './cache.service';
import Persons from '../database/entities/persons.entity';
import { Unit } from '../database/entities/unit.entity';
import { WorkspaceTestResultsService } from '../database/services/workspace-test-results.service';

@Injectable()
export class ResponseCacheSchedulerService {
private readonly logger = new Logger(ResponseCacheSchedulerService.name);

constructor(
private readonly cacheService: CacheService,
private readonly workspaceTestResultsService: WorkspaceTestResultsService,
@InjectRepository(Persons)
private readonly personsRepository: Repository<Persons>,
@InjectRepository(Unit)
private readonly unitRepository: Repository<Unit>
) {}

/**
* Scheduled task to cache all possible replay URLs and their responses
* Runs every night at 2:00 AM
*/
@Cron(CronExpression.EVERY_DAY_AT_1AM)
async cacheAllResponses() {
this.logger.log('Starting nightly task to cache all responses');

try {
// Get all workspaces with persons
const workspaces = await this.getWorkspacesWithPersons();

for (const workspace of workspaces) {
const workspaceId = workspace.workspace_id;
this.logger.log(`Caching responses for workspace ${workspaceId}`);

// Get all test persons in this workspace
const persons = await this.personsRepository.find({
where: { workspace_id: workspaceId, consider: true }
});

for (const person of persons) {
// Get all units for this person
const units = await this.getUnitsForPerson(person.id);

for (const unit of units) {
// Create the connector string (login@code@bookletId)
const connector = this.createConnector(person, unit.booklet.bookletinfo.name);

try {
// Cache the response
await this.cacheResponse(workspaceId, connector, unit.alias);
} catch (error) {
this.logger.error(`Error caching response for workspace=${workspaceId}, testPerson=${connector}, unitId=${unit.alias}: ${error.message}`, error.stack);
}
}
}
}

this.logger.log('Finished nightly caching of all responses');
} catch (error) {
this.logger.error(`Error in cacheAllResponses: ${error.message}`, error.stack);
}
}

/**
* Get all workspaces that have persons
*/
private async getWorkspacesWithPersons(): Promise<{ workspace_id: number }[]> {
return this.personsRepository
.createQueryBuilder('person')
.select('DISTINCT person.workspace_id', 'workspace_id')
.where('person.consider = :consider', { consider: true })
.getRawMany();
}

/**
* Get all units for a person
*/
private async getUnitsForPerson(personId: number): Promise<Unit[]> {
return this.unitRepository
.createQueryBuilder('unit')
.leftJoinAndSelect('unit.booklet', 'booklet')
.leftJoinAndSelect('booklet.bookletinfo', 'bookletInfo')
.where('booklet.personid = :personId', { personId })
.getMany();
}

/**
* Create a connector string for a person and booklet
*/
private createConnector(person: Persons, bookletId: string): string {
return `${person.login}@${person.code}@${bookletId}`;
}

/**
* Cache a response for a specific workspace, test person, and unit
*/
private async cacheResponse(workspaceId: number, connector: string, unitId: string): Promise<void> {
const cacheKey = this.cacheService.generateUnitResponseCacheKey(workspaceId, connector, unitId);

// Check if already in cache
const exists = await this.cacheService.exists(cacheKey);
if (exists) {
this.logger.debug(`Response already in cache: workspace=${workspaceId}, testPerson=${connector}, unitId=${unitId}`);
return;
}

// Fetch and cache the response
try {
const response = await this.workspaceTestResultsService.findUnitResponse(workspaceId, connector, unitId);
await this.cacheService.set(cacheKey, response);
this.logger.debug(`Cached response: workspace=${workspaceId}, testPerson=${connector}, unitId=${unitId}`);
} catch (error) {
this.logger.error(`Error fetching response for caching: ${error.message}`, error.stack);
throw error;
}
}
}
13 changes: 3 additions & 10 deletions apps/backend/src/app/database/database.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,9 @@ import { ReplayStatistics } from './entities/replay-statistics.entity';
import { ReplayStatisticsService } from './services/replay-statistics.service';
// eslint-disable-next-line import/no-cycle
import { JobQueueModule } from '../job-queue/job-queue.module';
// eslint-disable-next-line import/no-cycle
import { CacheModule } from '../cache/cache.module';

/**
* DatabaseModule provides database access and services for the application.
*
* Note: This module has a circular dependency with JobQueueModule because:
* - DatabaseModule exports WorkspaceCodingService which is used by JobQueueModule
* - DatabaseModule imports JobQueueModule for job queue functionality
*
* This circular dependency is resolved using forwardRef() both at the module level
* and at the injection point in the TestPersonCodingProcessor.
*/
@Module({
imports: [
User,
Expand All @@ -79,6 +71,7 @@ import { JobQueueModule } from '../job-queue/job-queue.module';
WorkspaceUser,
HttpModule,
forwardRef(() => JobQueueModule),
forwardRef(() => CacheModule),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
Expand Down
Loading