diff --git a/src/app.module.ts b/src/app.module.ts index 5368cac2..b4e511a6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module, DynamicModule, Type, Global } from '@nestjs/common'; +import { Module, DynamicModule, Type, Logger, Global } from '@nestjs/common'; import { APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -88,6 +88,13 @@ export class AppModule { 10, ); + // Log pool configuration at startup for observability (#274) + const poolLogger = new Logger('DatabasePool'); + poolLogger.log( + `DB pool config — max: ${poolMax}, min: ${poolMin}, ` + + `acquireTimeout: ${poolAcquireTimeoutMs}ms, idleTimeout: ${poolIdleTimeoutMs}ms`, + ); + return { type: 'postgres', host: process.env.DATABASE_HOST || 'localhost', @@ -106,6 +113,17 @@ export class AppModule { min: poolMin, connectionTimeoutMillis: poolAcquireTimeoutMs, idleTimeoutMillis: poolIdleTimeoutMs, + // Pool event hooks for Prometheus metrics (#274). + // pg fires these on the underlying Pool instance after each + // acquire/release so we can track connection churn over time. + afterPoolConnect: (_client: unknown, _eventCount: number) => { + metricsService.dbPoolConnectionsAcquired.inc(); + metricsService.dbPoolSize.inc(); + }, + afterPoolRelease: (_client: unknown, _eventCount: number) => { + metricsService.dbPoolConnectionsReleased.inc(); + metricsService.dbPoolSize.dec(); + }, }, }; }, diff --git a/src/caching/caching.constants.ts b/src/caching/caching.constants.ts index 7b911828..5b17445f 100644 --- a/src/caching/caching.constants.ts +++ b/src/caching/caching.constants.ts @@ -1,5 +1,37 @@ export const CACHE_REDIS_CLIENT = 'CACHE_REDIS_CLIENT'; +/** + * Cache TTL (Time-To-Live) values in seconds. + * + * ## Caching Strategy + * + * TeachLink uses a Redis-backed cache-aside pattern: + * 1. On read, check cache first; on miss, fetch from DB and populate cache. + * 2. On write/delete, invalidate or update the relevant cache keys. + * + * ### TTL Policy + * TTLs are chosen based on data volatility and read frequency: + * + * | Key | TTL | Rationale | + * |------------------|------------|------------------------------------------------| + * | USER_SESSION | 7 days | Long-lived auth sessions; invalidated on logout| + * | COURSE_METADATA | 15 min | Frequently read, infrequently updated | + * | COURSE_DETAILS | 5 min | May change (price, seats); short TTL | + * | SEARCH_RESULTS | 2 min | High read volume; stale results acceptable | + * | USER_PROFILE | 10 min | Moderate update frequency | + * | STATIC_CONTENT | 1 hour | Rarely changes; safe to cache long | + * | POPULAR_COURSES | 30 min | Computed ranking; refresh periodically | + * | ENROLLMENT_DATA | 5 min | Changes on enroll/unenroll events | + * + * ### Cache Key Versioning + * All keys are prefixed with `cache:` (see CACHE_PREFIXES). + * To bust all keys for an entity type, increment the version suffix: + * e.g. `cache:course:v2:` + * + * ### Invalidation + * Cache invalidation is event-driven via CACHE_EVENTS. Services emit these + * events after mutations; the CachingModule subscribes and deletes stale keys. + */ export const CACHE_TTL = { USER_SESSION: 604800, // 7 days COURSE_METADATA: 900, // 15 minutes diff --git a/src/common/exceptions/app.exceptions.ts b/src/common/exceptions/app.exceptions.ts new file mode 100644 index 00000000..ccb2ec5e --- /dev/null +++ b/src/common/exceptions/app.exceptions.ts @@ -0,0 +1,62 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +/** + * Thrown when a requested resource cannot be found. + * Maps to HTTP 404 Not Found. + */ +export class ResourceNotFoundException extends HttpException { + constructor(resource: string, id?: string | number) { + const message = id ? `${resource} with id '${id}' was not found` : `${resource} was not found`; + super({ message, error: 'Not Found', statusCode: HttpStatus.NOT_FOUND }, HttpStatus.NOT_FOUND); + } +} + +/** + * Thrown when an operation is not permitted for the current user/state. + * Maps to HTTP 403 Forbidden. + */ +export class ForbiddenOperationException extends HttpException { + constructor(message = 'You do not have permission to perform this action') { + super({ message, error: 'Forbidden', statusCode: HttpStatus.FORBIDDEN }, HttpStatus.FORBIDDEN); + } +} + +/** + * Thrown when a resource already exists (e.g. duplicate email). + * Maps to HTTP 409 Conflict. + */ +export class ResourceConflictException extends HttpException { + constructor(resource: string, field?: string) { + const message = field + ? `${resource} with this ${field} already exists` + : `${resource} already exists`; + super({ message, error: 'Conflict', statusCode: HttpStatus.CONFLICT }, HttpStatus.CONFLICT); + } +} + +/** + * Thrown when input data fails business-rule validation (beyond DTO constraints). + * Maps to HTTP 422 Unprocessable Entity. + */ +export class BusinessValidationException extends HttpException { + constructor(message: string) { + super( + { message, error: 'Unprocessable Entity', statusCode: HttpStatus.UNPROCESSABLE_ENTITY }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } +} + +/** + * Thrown when an external service or dependency is unavailable. + * Maps to HTTP 503 Service Unavailable. + */ +export class ServiceUnavailableException extends HttpException { + constructor(service: string) { + const message = `${service} is currently unavailable. Please try again later.`; + super( + { message, error: 'Service Unavailable', statusCode: HttpStatus.SERVICE_UNAVAILABLE }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } +} diff --git a/src/monitoring/metrics/metrics-collection.service.ts b/src/monitoring/metrics/metrics-collection.service.ts index c143d274..bb67b03d 100644 --- a/src/monitoring/metrics/metrics-collection.service.ts +++ b/src/monitoring/metrics/metrics-collection.service.ts @@ -7,6 +7,12 @@ export class MetricsCollectionService implements OnModuleInit { public httpRequestDuration: Histogram; public dbQueryDuration: Histogram; public activeConnections: Gauge; + /** Tracks total DB pool connections acquired since startup (#274) */ + public dbPoolConnectionsAcquired: Counter; + /** Tracks total DB pool connections released since startup (#274) */ + public dbPoolConnectionsReleased: Counter; + /** Tracks current DB pool size (active + idle) (#274) */ + public dbPoolSize: Gauge; public userRegistrations: Counter; public assessmentCompletions: Counter; public learningPathProgress: Gauge; @@ -43,6 +49,22 @@ export class MetricsCollectionService implements OnModuleInit { registers: [this.registry], }); + // DB connection pool metrics (#274) + this.dbPoolConnectionsAcquired = new Counter({ + name: 'db_pool_connections_acquired_total', + help: 'Total number of DB pool connections acquired', + registers: [this.registry], + }); + + this.dbPoolConnectionsReleased = new Counter({ + name: 'db_pool_connections_released_total', + help: 'Total number of DB pool connections released', + registers: [this.registry], + }); + + this.dbPoolSize = new Gauge({ + name: 'db_pool_size', + help: 'Current DB connection pool size (active + idle)', // User Registrations Counter this.userRegistrations = new Counter({ name: 'user_registrations_total',