Skip to content

Commit bb8db99

Browse files
Merge pull request #221 from Oluwaseyi89/feature/Configure-Database-Connection-Pooling
feat(database): configure TypeORM connection pooling and unblock app startup
2 parents 5a082ea + cb638ee commit bb8db99

File tree

11 files changed

+120
-30
lines changed

11 files changed

+120
-30
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@ DATABASE_PORT=5432
44
DATABASE_USER=postgres
55
DATABASE_PASSWORD=postgres
66
DATABASE_NAME=teachlink
7+
DATABASE_POOL_MAX=30
8+
DATABASE_POOL_MIN=5
9+
DATABASE_POOL_ACQUIRE_TIMEOUT_MS=10000
10+
DATABASE_POOL_IDLE_TIMEOUT_MS=30000
711

812
# JWT Configuration
913
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production-min-10-chars
14+
ENCRYPTION_SECRET=your-super-secret-32-char-encryption-key-change-this
1015
JWT_EXPIRES_IN=15m
1116
JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-in-production
1217
JWT_REFRESH_EXPIRES_IN=7d

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ TeachLink Backend provides secure and scalable APIs to power features such as:
145145
The application uses strategic database indexes to optimize query performance, especially for frequently accessed data and pagination operations.
146146

147147
#### Single Column Indexes
148+
148149
- **User.email**: Unique index for authentication lookups
149150
- **User.username**: Index for profile searches
150151
- **User.tenantId**: Index for multi-tenant queries
@@ -162,17 +163,57 @@ The application uses strategic database indexes to optimize query performance, e
162163
- **Lesson.moduleId**: Index for module lesson queries
163164

164165
#### Composite Indexes
166+
165167
- **Enrollment (userId, status)**: Optimized for user enrollment status queries
166168
- **Enrollment (courseId, status)**: Optimized for course enrollment analytics
167169
- **Payment (userId, status)**: Optimized for user payment status filtering
168170
- **Subscription (userId, status)**: Optimized for user subscription status queries
169171

170172
#### Performance Considerations
173+
171174
- Indexes are added to foreign key columns to improve JOIN performance
172175
- Composite indexes support common query patterns (e.g., filtering by user + status)
173176
- Partial indexes are used where applicable for better selectivity
174177
- Index maintenance overhead is monitored to ensure write performance is not negatively impacted
175178

179+
### Connection Pooling (TypeORM + PostgreSQL)
180+
181+
The backend now supports explicit database pool tuning through environment variables:
182+
183+
- `DATABASE_POOL_MAX` (default: `30`)
184+
- `DATABASE_POOL_MIN` (default: `5`)
185+
- `DATABASE_POOL_ACQUIRE_TIMEOUT_MS` (default: `10000`)
186+
- `DATABASE_POOL_IDLE_TIMEOUT_MS` (default: `30000`)
187+
188+
Recommended starting points:
189+
190+
| Environment | `DATABASE_POOL_MAX` | `DATABASE_POOL_MIN` | Acquire Timeout | Idle Timeout |
191+
| ----------- | ------------------- | ------------------- | --------------- | ------------ |
192+
| Development | 10 | 2 | 5000 ms | 10000 ms |
193+
| Staging | 20 | 5 | 10000 ms | 30000 ms |
194+
| Production | 30 to 60 | 5 to 10 | 10000 ms | 30000 ms |
195+
196+
Sizing rule:
197+
198+
- Keep total active connections across workers below PostgreSQL capacity.
199+
- Formula: `DATABASE_POOL_MAX x app_instances x cluster_workers <= postgres_max_connections - reserved_connections`.
200+
- Reserve at least 20 to 30 connections for migrations, admin access, and background jobs.
201+
202+
Load testing checklist:
203+
204+
```bash
205+
# 1) Start API
206+
npm run start:dev
207+
208+
# 2) In another terminal, run concurrent load against a DB-backed endpoint
209+
npx autocannon -c 100 -d 60 http://localhost:3000/health
210+
211+
# 3) Observe active connections in PostgreSQL (replace DB name)
212+
psql -d teachlink -c "select count(*) as active_connections from pg_stat_activity where datname='teachlink';"
213+
```
214+
215+
Expected result: no connection-acquire timeouts, stable latency under sustained concurrency, and active connections staying within configured pool bounds.
216+
176217
## �🚀 Deployment
177218

178219
### Prerequisites
@@ -256,6 +297,7 @@ DB_PASSWORD=yourpassword
256297
DB_NAME=teachlink
257298

258299
JWT_SECRET=your_jwt_secret
300+
ENCRYPTION_SECRET=your_32_char_encryption_secret
259301
JWT_EXPIRATION=3600
260302

261303
CLOUDINARY_API_KEY=your_key

src/ab-testing/ab-testing.controller.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
HttpCode,
1212
HttpStatus,
1313
UseGuards,
14-
Request,
1514
} from '@nestjs/common';
1615
import { ABTestingService, CreateExperimentDto } from './ab-testing.service';
1716
import { ExperimentService } from './experiments/experiment.service';
@@ -52,7 +51,7 @@ export class ABTestingController {
5251
@Post('experiments')
5352
@HttpCode(HttpStatus.CREATED)
5453
@Roles(UserRole.ADMIN)
55-
async createExperiment(@Request() req, @Body() createExperimentDto: CreateExperimentDto) {
54+
async createExperiment(@Body() createExperimentDto: CreateExperimentDto) {
5655
this.logger.log(`Creating new experiment: ${createExperimentDto.name}`);
5756
return await this.abTestingService.createExperiment(createExperimentDto);
5857
}

src/ab-testing/analysis/statistical-analysis.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export class StatisticalAnalysisService {
3636
const results = {
3737
experimentId: experiment.id,
3838
confidenceLevel: experiment.confidenceLevel,
39-
variants: [],
39+
variants: [] as any[],
4040
statisticallySignificant: false,
4141
};
4242

@@ -70,7 +70,7 @@ export class StatisticalAnalysisService {
7070
variantId: variant.id,
7171
variantName: variant.name,
7272
isControl: variant.isControl,
73-
metrics: [],
73+
metrics: [] as any[],
7474
overallPerformance: 0,
7575
};
7676

src/app.module.ts

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Module, DynamicModule } from '@nestjs/common';
1+
import { Module, DynamicModule, Type } from '@nestjs/common';
22
import { APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
33
import { ConfigModule } from '@nestjs/config';
44
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -40,13 +40,13 @@ import { SearchModule } from './search/search.module';
4040
import { NotificationsModule } from './notifications/notifications.module';
4141
import { EmailMarketingModule } from './email-marketing/email-marketing.module';
4242
import { GamificationModule } from './gamification/gamification.module';
43-
import { AssessmentModule } from './assessment/assessment.module';
43+
import { AssessmentsModule } from './assessment/assessment.module';
4444
import { LearningPathsModule } from './learning-paths/learning-paths.module';
4545
import { ModerationModule } from './moderation/moderation.module';
4646
import { OrchestrationModule } from './orchestration/orchestration.module';
4747
import { SecurityModule } from './security/security.module';
4848
import { TenancyModule } from './tenancy/tenancy.module';
49-
import { CDNModule } from './cdn/cdn.module';
49+
import { CdnModule } from './cdn/cdn.module';
5050
import { AuthModule } from './auth/auth.module';
5151
import { PaymentsModule } from './payments/payments.module';
5252

@@ -65,19 +65,41 @@ export class AppModule {
6565
TypeOrmModule.forRootAsync({
6666
imports: [MonitoringModule],
6767
inject: [MetricsCollectionService],
68-
useFactory: (metricsService: MetricsCollectionService) => ({
69-
type: 'postgres',
70-
host: process.env.DATABASE_HOST || 'localhost',
71-
port: parseInt(process.env.DATABASE_PORT || '5432'),
72-
username: process.env.DATABASE_USER || 'postgres',
73-
password: process.env.DATABASE_PASSWORD || 'postgres',
74-
database: process.env.DATABASE_NAME || 'teachlink',
75-
autoLoadEntities: true,
76-
synchronize: process.env.NODE_ENV !== 'production',
77-
logging: true,
78-
logger: new TypeOrmMonitoringLogger(metricsService),
79-
maxQueryExecutionTime: 1000,
80-
}),
68+
useFactory: (metricsService: MetricsCollectionService) => {
69+
// Tune postgres pool to avoid connection exhaustion in high-traffic workloads.
70+
// Values can be overridden with DATABASE_POOL_* environment variables.
71+
const poolMax = parseInt(process.env.DATABASE_POOL_MAX || '30', 10);
72+
const poolMin = parseInt(process.env.DATABASE_POOL_MIN || '5', 10);
73+
const poolAcquireTimeoutMs = parseInt(
74+
process.env.DATABASE_POOL_ACQUIRE_TIMEOUT_MS || '10000',
75+
10,
76+
);
77+
const poolIdleTimeoutMs = parseInt(
78+
process.env.DATABASE_POOL_IDLE_TIMEOUT_MS || '30000',
79+
10,
80+
);
81+
82+
return {
83+
type: 'postgres',
84+
host: process.env.DATABASE_HOST || 'localhost',
85+
port: parseInt(process.env.DATABASE_PORT || '5432'),
86+
username: process.env.DATABASE_USER || 'postgres',
87+
password: process.env.DATABASE_PASSWORD || 'postgres',
88+
database: process.env.DATABASE_NAME || 'teachlink',
89+
autoLoadEntities: true,
90+
synchronize: process.env.NODE_ENV !== 'production',
91+
logging: true,
92+
logger: new TypeOrmMonitoringLogger(metricsService),
93+
maxQueryExecutionTime: 1000,
94+
extra: {
95+
// pg Pool options used by TypeORM postgres driver
96+
max: poolMax,
97+
min: poolMin,
98+
connectionTimeoutMillis: poolAcquireTimeoutMs,
99+
idleTimeoutMillis: poolIdleTimeoutMs,
100+
},
101+
};
102+
},
81103
}),
82104
MonitoringModule,
83105
EventEmitterModule.forRoot(),
@@ -106,7 +128,7 @@ export class AppModule {
106128
];
107129

108130
// Feature modules - conditionally loaded based on feature flags
109-
const featureModules: DynamicModule[] = [];
131+
const featureModules: Array<DynamicModule | Type<unknown>> = [];
110132

111133
// Auth Module
112134
if (flags.ENABLE_AUTH) {
@@ -273,7 +295,7 @@ export class AppModule {
273295
// Assessment Module
274296
if (flags.ENABLE_ASSESSMENT) {
275297
const startTime = Date.now();
276-
featureModules.push(AssessmentModule);
298+
featureModules.push(AssessmentsModule);
277299
startupLogger.recordModuleLoaded('AssessmentModule', startTime);
278300
} else {
279301
startupLogger.recordModuleSkipped('AssessmentModule', 'ENABLE_ASSESSMENT=false');
@@ -327,7 +349,7 @@ export class AppModule {
327349
// CDN Module
328350
if (flags.ENABLE_CDN) {
329351
const startTime = Date.now();
330-
featureModules.push(CDNModule);
352+
featureModules.push(CdnModule);
331353
startupLogger.recordModuleLoaded('CDNModule', startTime);
332354
} else {
333355
startupLogger.recordModuleSkipped('CDNModule', 'ENABLE_CDN=false');

src/config/env.validation.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export const envValidationSchema = Joi.object({
1010
DATABASE_USER: Joi.string().required(),
1111
DATABASE_PASSWORD: Joi.string().required(),
1212
DATABASE_NAME: Joi.string().required(),
13+
DATABASE_POOL_MAX: Joi.number().integer().min(1).default(30),
14+
DATABASE_POOL_MIN: Joi.number().integer().min(0).default(5),
15+
DATABASE_POOL_ACQUIRE_TIMEOUT_MS: Joi.number().integer().min(1000).default(10000),
16+
DATABASE_POOL_IDLE_TIMEOUT_MS: Joi.number().integer().min(1000).default(30000),
1317

1418
REDIS_HOST: Joi.string().required(),
1519
REDIS_PORT: Joi.number().required(),
@@ -18,4 +22,5 @@ export const envValidationSchema = Joi.object({
1822
THROTTLE_LIMIT: Joi.number().default(10),
1923

2024
JWT_SECRET: Joi.string().min(10).required(),
25+
ENCRYPTION_SECRET: Joi.string().min(32).required(),
2126
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { Module } from '@nestjs/common';
2+
3+
@Module({})
4+
export class NotificationsModule {}

src/orchestration/orchestration.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Module, Global } from '@nestjs/common';
2+
import { HttpModule } from '@nestjs/axios';
23
import { ServiceMeshService } from './service-mesh/service-mesh.service';
34
import { WorkflowEngineService } from './workflow/workflow-engine.service';
45
import { DistributedLockService } from './locks/distributed-lock.service';
@@ -7,6 +8,7 @@ import { HealthCheckerService } from './health/health-checker.service';
78

89
@Global()
910
@Module({
11+
imports: [HttpModule],
1012
providers: [
1113
ServiceMeshService,
1214
WorkflowEngineService,

src/payments/payments.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ import { Subscription } from './entities/subscription.entity';
1414
import { Invoice } from './entities/invoice.entity';
1515
import { Refund } from './entities/refund.entity';
1616
import { UsersModule } from '../users/users.module';
17+
import { User } from '../users/entities/user.entity';
1718
import { TransactionService } from '../common/database/transaction.service';
1819
import { TransactionHelperService } from '../common/database/transaction-helper.service';
1920

2021
@Module({
2122
imports: [
22-
TypeOrmModule.forFeature([Payment, Subscription, Invoice, Refund]),
23+
TypeOrmModule.forFeature([Payment, Subscription, Invoice, Refund, User]),
2324
BullModule.registerQueue({
2425
name: 'subscriptions',
2526
}),

src/security/encryption/encryption.service.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,17 @@ import * as crypto from 'crypto';
44
@Injectable()
55
export class EncryptionService {
66
private readonly algorithm = 'aes-256-gcm';
7-
private readonly key = crypto.createHash('sha256').update(process.env.ENCRYPTION_SECRET).digest();
7+
private readonly key = crypto.createHash('sha256').update(this.getEncryptionSecret()).digest();
8+
9+
private getEncryptionSecret(): string {
10+
const secret = process.env.ENCRYPTION_SECRET;
11+
12+
if (!secret) {
13+
throw new Error('ENCRYPTION_SECRET is required to initialize EncryptionService');
14+
}
15+
16+
return secret;
17+
}
818

919
encrypt(text: string) {
1020
const iv = crypto.randomBytes(16);

0 commit comments

Comments
 (0)