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
9 changes: 9 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ThrottlerModule } from '@nestjs/throttler';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { CorrelationIdInterceptor } from './common/interceptors/correlation-id.interceptor';
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
import { GracefulShutdownInterceptor } from './common/interceptors/graceful-shutdown.interceptor';
import { TieredThrottlerGuard } from './common/guards/tiered-throttler.guard';
import { CommonModule } from './common/common.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
Expand Down Expand Up @@ -37,6 +38,8 @@ import { TestRbacModule } from './test-rbac/test-rbac.module';
import { TestThrottlingModule } from './test-throttling/test-throttling.module';
import { ApiVersioningModule } from './common/versioning/api-versioning.module';
import { BackupModule } from './modules/backup/backup.module';
import { PerformanceModule } from './modules/performance/performance.module';
import { GracefulShutdownService } from './common/services/graceful-shutdown.service';

const envValidationSchema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').required(),
Expand Down Expand Up @@ -190,6 +193,7 @@ const envValidationSchema = Joi.object({
TestThrottlingModule,
ApiVersioningModule,
BackupModule,
PerformanceModule,
CommonModule,
ThrottlerModule.forRoot([
{
Expand All @@ -212,6 +216,7 @@ const envValidationSchema = Joi.object({
controllers: [AppController],
providers: [
AppService,
GracefulShutdownService,
{
provide: APP_GUARD,
useClass: TieredThrottlerGuard,
Expand All @@ -224,6 +229,10 @@ const envValidationSchema = Joi.object({
provide: APP_INTERCEPTOR,
useClass: AuditLogInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: GracefulShutdownInterceptor,
},
],
})
export class AppModule {}
12 changes: 12 additions & 0 deletions backend/src/common/decorators/cache-config.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { SetMetadata } from '@nestjs/common';

export interface CacheConfigMetadata {
ttl?: number;
tags?: string[];
staleWhileRevalidate?: boolean;
}

export const CACHE_CONFIG_KEY = 'cache_config';

export const CacheConfig = (config: CacheConfigMetadata) =>
SetMetadata(CACHE_CONFIG_KEY, config);
38 changes: 38 additions & 0 deletions backend/src/common/interceptors/cache.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CacheStrategyService } from '../../modules/cache/cache-strategy.service';

@Injectable()
export class CacheInterceptor implements NestInterceptor {
constructor(private cacheStrategy: CacheStrategyService) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url, query } = request;

// Only cache GET requests
if (method !== 'GET') {
return next.handle();
}

const cacheKey = `${url}:${JSON.stringify(query)}`;

return this.cacheStrategy.get(cacheKey).then((cached) => {
if (cached) {
return of(cached);
}

return next.handle().pipe(
tap((data) => {
this.cacheStrategy.set(cacheKey, data);
}),
);
});
}
}
34 changes: 34 additions & 0 deletions backend/src/common/interceptors/graceful-shutdown.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { finalize } from 'rxjs/operators';
import { GracefulShutdownService } from '../services/graceful-shutdown.service';

@Injectable()
export class GracefulShutdownInterceptor implements NestInterceptor {
constructor(private gracefulShutdown: GracefulShutdownService) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// Reject new requests during shutdown
if (this.gracefulShutdown.isShutdown()) {
const response = context.switchToHttp().getResponse();
response.status(503).json({
statusCode: 503,
message: 'Service is shutting down',
});
return;
}

this.gracefulShutdown.incrementActiveRequests();

return next.handle().pipe(
finalize(() => {
this.gracefulShutdown.decrementActiveRequests();
}),
);
}
}
103 changes: 103 additions & 0 deletions backend/src/common/services/graceful-shutdown.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Injectable, Logger, OnApplicationShutdown } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject } from '@nestjs/common';
import { Cache } from 'cache-manager';

@Injectable()
export class GracefulShutdownService implements OnApplicationShutdown {
private readonly logger = new Logger(GracefulShutdownService.name);
private isShuttingDown = false;
private activeRequests = 0;
private readonly maxShutdownTimeout = 30000; // 30 seconds

constructor(
private dataSource: DataSource,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}

incrementActiveRequests(): void {
if (!this.isShuttingDown) {
this.activeRequests++;
}
}

decrementActiveRequests(): void {
this.activeRequests--;
}

isShutdown(): boolean {
return this.isShuttingDown;
}

async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(`Received shutdown signal: ${signal}`);
this.isShuttingDown = true;

const shutdownStartTime = Date.now();

// Stop accepting new requests
this.logger.log('Stopping acceptance of new requests');

// Wait for in-flight requests to complete
await this.waitForInFlightRequests();

// Close database connections
await this.closeDatabase();

// Close Redis connections
await this.closeRedis();

const shutdownDuration = Date.now() - shutdownStartTime;
this.logger.log(
`Graceful shutdown completed in ${shutdownDuration}ms`,
);
}

private async waitForInFlightRequests(): Promise<void> {
const startTime = Date.now();
const timeout = 25000; // Leave 5 seconds for other cleanup

while (this.activeRequests > 0) {
const elapsed = Date.now() - startTime;

if (elapsed > timeout) {
this.logger.warn(
`Timeout waiting for ${this.activeRequests} in-flight requests. Forcing shutdown.`,
);
break;
}

this.logger.log(
`Waiting for ${this.activeRequests} in-flight requests to complete...`,
);
await new Promise((resolve) => setTimeout(resolve, 1000));
}

this.logger.log('All in-flight requests completed');
}

private async closeDatabase(): Promise<void> {
try {
if (this.dataSource && this.dataSource.isInitialized) {
this.logger.log('Closing database connections...');
await this.dataSource.destroy();
this.logger.log('Database connections closed');
}
} catch (error) {
this.logger.error('Error closing database connections:', error);
}
}

private async closeRedis(): Promise<void> {
try {
if (this.cacheManager) {
this.logger.log('Closing Redis connections...');
await this.cacheManager.reset();
this.logger.log('Redis connections closed');
}
} catch (error) {
this.logger.error('Error closing Redis connections:', error);
}
}
}
28 changes: 27 additions & 1 deletion backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from './common/versioning/versioning.middleware';
import { VersionAnalyticsInterceptor } from './common/versioning/version-analytics.interceptor';
import { VersionAnalyticsService } from './common/versioning/version-analytics.service';
import { GracefulShutdownService } from './common/services/graceful-shutdown.service';

async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
Expand Down Expand Up @@ -57,13 +58,38 @@ async function bootstrap() {
SwaggerModule.setup(`api/v${version}/docs`, app, document);
}

await app.listen(port || 3001);
const server = await app.listen(port || 3001);
const logger = app.get(Logger);
logger.log(`Application is running on: http://localhost:${port}/api`);
logger.log(
`Swagger v1 docs (deprecated): http://localhost:${port}/api/v1/docs`,
);
logger.log(`Swagger v2 docs: http://localhost:${port}/api/v2/docs`);

// Setup graceful shutdown
const gracefulShutdown = app.get(GracefulShutdownService);

const signals = ['SIGTERM', 'SIGINT'];
signals.forEach((signal) => {
process.on(signal, async () => {
logger.log(`Received ${signal}, starting graceful shutdown...`);
server.close(async () => {
await app.close();
process.exit(0);
});
});
});

// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error);
process.exit(1);
});

process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});
}

bootstrap().catch((error: unknown) => {
Expand Down
Loading
Loading