fix(server): auto-reconnect to database (#12320)

This commit is contained in:
Jason Rasmussen 2024-09-04 13:32:43 -04:00 committed by GitHub
parent 1783dfd393
commit 12b65e3c24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 130 additions and 46 deletions

View File

@ -18,10 +18,11 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthGuard } from 'src/middleware/auth.guard'; import { AuthGuard } from 'src/middleware/auth.guard';
import { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { ErrorInterceptor } from 'src/middleware/error.interceptor';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { HttpExceptionFilter } from 'src/middleware/http-exception.filter'; import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { LoggingInterceptor } from 'src/middleware/logging.interceptor'; import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { repositories } from 'src/repositories'; import { repositories } from 'src/repositories';
import { services } from 'src/services'; import { services } from 'src/services';
import { DatabaseService } from 'src/services/database.service';
import { setupEventHandlers } from 'src/utils/events'; import { setupEventHandlers } from 'src/utils/events';
import { otelConfig } from 'src/utils/instrumentation'; import { otelConfig } from 'src/utils/instrumentation';
@ -29,7 +30,7 @@ const common = [...services, ...repositories];
const middleware = [ const middleware = [
FileUploadInterceptor, FileUploadInterceptor,
{ provide: APP_FILTER, useClass: HttpExceptionFilter }, { provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) }, { provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor }, { provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
@ -43,7 +44,17 @@ const imports = [
ConfigModule.forRoot(immichAppConfig), ConfigModule.forRoot(immichAppConfig),
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
OpenTelemetryModule.forRoot(otelConfig), OpenTelemetryModule.forRoot(otelConfig),
TypeOrmModule.forRoot(databaseConfig), TypeOrmModule.forRootAsync({
inject: [ModuleRef],
useFactory: (moduleRef: ModuleRef) => {
return {
...databaseConfig,
poolErrorHandler: (error) => {
moduleRef.get(DatabaseService, { strict: false }).handleConnectionError(error);
},
};
},
}),
TypeOrmModule.forFeature(entities), TypeOrmModule.forFeature(entities),
]; ];

View File

@ -40,6 +40,7 @@ export interface VectorUpdateResult {
export const IDatabaseRepository = 'IDatabaseRepository'; export const IDatabaseRepository = 'IDatabaseRepository';
export interface IDatabaseRepository { export interface IDatabaseRepository {
reconnect(): Promise<boolean>;
getExtensionVersion(extension: DatabaseExtension): Promise<ExtensionVersion>; getExtensionVersion(extension: DatabaseExtension): Promise<ExtensionVersion>;
getExtensionVersionRange(extension: VectorExtension): string; getExtensionVersionRange(extension: VectorExtension): string;
getPostgresVersion(): Promise<string>; getPostgresVersion(): Promise<string>;

View File

@ -9,6 +9,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Observable, catchError, throwError } from 'rxjs'; import { Observable, catchError, throwError } from 'rxjs';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { logGlobalError } from 'src/utils/logger';
import { routeToErrorMessage } from 'src/utils/misc'; import { routeToErrorMessage } from 'src/utils/misc';
@Injectable() @Injectable()
@ -25,9 +26,10 @@ export class ErrorInterceptor implements NestInterceptor {
return error; return error;
} }
const errorMessage = routeToErrorMessage(context.getHandler().name); logGlobalError(this.logger, error);
this.logger.error(errorMessage, error, error?.errors, error?.stack);
return new InternalServerErrorException(errorMessage); const message = routeToErrorMessage(context.getHandler().name);
return new InternalServerErrorException(message);
}), }),
), ),
); );

View File

@ -0,0 +1,47 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Inject } from '@nestjs/common';
import { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { logGlobalError } from 'src/utils/logger';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter<Error> {
constructor(
@Inject(ILoggerRepository) private logger: ILoggerRepository,
private cls: ClsService,
) {
this.logger.setContext(GlobalExceptionFilter.name);
}
catch(error: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const { status, body } = this.fromError(error);
if (!response.headersSent) {
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
}
}
private fromError(error: Error) {
logGlobalError(this.logger, error);
if (error instanceof HttpException) {
const status = error.getStatus();
let body = error.getResponse();
// unclear what circumstances would return a string
if (typeof body === 'string') {
body = { message: body };
}
return { status, body };
}
return {
status: 500,
body: {
message: 'Internal server error',
},
};
}
}

View File

@ -1,39 +0,0 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Inject } from '@nestjs/common';
import { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
constructor(
@Inject(ILoggerRepository) private logger: ILoggerRepository,
private cls: ClsService,
) {
this.logger.setContext(HttpExceptionFilter.name);
}
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
this.logger.debug(`HttpException(${status}) ${JSON.stringify(exception.getResponse())}`);
let responseBody = exception.getResponse();
// unclear what circumstances would return a string
if (typeof responseBody === 'string') {
responseBody = {
error: 'Unknown',
message: responseBody,
statusCode: status,
};
}
if (!response.headersSent) {
response.status(status).json({
...responseBody,
correlationId: this.cls.getId(),
});
}
}
}

View File

@ -31,6 +31,19 @@ export class DatabaseRepository implements IDatabaseRepository {
this.logger.setContext(DatabaseRepository.name); this.logger.setContext(DatabaseRepository.name);
} }
async reconnect() {
try {
if (this.dataSource.isInitialized) {
await this.dataSource.destroy();
}
const { isInitialized } = await this.dataSource.initialize();
return isInitialized;
} catch (error) {
this.logger.error(`Database connection failed: ${error}`);
return false;
}
}
async getExtensionVersion(extension: DatabaseExtension): Promise<ExtensionVersion> { async getExtensionVersion(extension: DatabaseExtension): Promise<ExtensionVersion> {
const [res]: ExtensionVersion[] = await this.dataSource.query( const [res]: ExtensionVersion[] = await this.dataSource.query(
`SELECT default_version as "availableVersion", installed_version as "installedVersion" `SELECT default_version as "availableVersion", installed_version as "installedVersion"

View File

@ -3,7 +3,7 @@ import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-en
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { LogLevel } from 'src/config'; import { LogLevel } from 'src/config';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { LogColor } from 'src/utils/logger-colors'; import { LogColor } from 'src/utils/logger';
const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL];

View File

@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Duration } from 'luxon';
import semver from 'semver'; import semver from 'semver';
import { getVectorExtension } from 'src/database.config'; import { getVectorExtension } from 'src/database.config';
import { OnEmit } from 'src/decorators'; import { OnEmit } from 'src/decorators';
@ -59,8 +60,12 @@ const messages = {
If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`, If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`,
}; };
const RETRY_DURATION = Duration.fromObject({ seconds: 5 });
@Injectable() @Injectable()
export class DatabaseService { export class DatabaseService {
private reconnection?: NodeJS.Timeout;
constructor( constructor(
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@ -117,6 +122,26 @@ export class DatabaseService {
}); });
} }
handleConnectionError(error: Error) {
if (this.reconnection) {
return;
}
this.logger.error(`Database disconnected: ${error}`);
this.reconnection = setInterval(() => void this.reconnect(), RETRY_DURATION.toMillis());
}
private async reconnect() {
const isConnected = await this.databaseRepository.reconnect();
if (isConnected) {
this.logger.log('Database reconnected');
clearInterval(this.reconnection);
delete this.reconnection;
} else {
this.logger.warn(`Database connection failed, retrying in ${RETRY_DURATION.toHuman()}`);
}
}
private async createExtension(extension: DatabaseExtension) { private async createExtension(extension: DatabaseExtension) {
try { try {
await this.databaseRepository.createExtension(extension); await this.databaseRepository.createExtension(extension);

View File

@ -1,3 +1,7 @@
import { HttpException } from '@nestjs/common';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { TypeORMError } from 'typeorm';
type ColorTextFn = (text: string) => string; type ColorTextFn = (text: string) => string;
const isColorAllowed = () => !process.env.NO_COLOR; const isColorAllowed = () => !process.env.NO_COLOR;
@ -15,3 +19,22 @@ export const LogColor = {
export const LogStyle = { export const LogStyle = {
bold: colorIfAllowed((text: string) => `\u001B[1m${text}\u001B[0m`), bold: colorIfAllowed((text: string) => `\u001B[1m${text}\u001B[0m`),
}; };
export const logGlobalError = (logger: ILoggerRepository, error: Error) => {
if (error instanceof HttpException) {
const status = error.getStatus();
const response = error.getResponse();
logger.debug(`HttpException(${status}): ${JSON.stringify(response)}`);
return;
}
if (error instanceof TypeORMError) {
logger.error(`Database error: ${error}`);
return;
}
if (error instanceof Error) {
logger.error(`Unknown error: ${error}`);
return;
}
};

View File

@ -3,6 +3,7 @@ import { Mocked, vitest } from 'vitest';
export const newDatabaseRepositoryMock = (): Mocked<IDatabaseRepository> => { export const newDatabaseRepositoryMock = (): Mocked<IDatabaseRepository> => {
return { return {
reconnect: vitest.fn(),
getExtensionVersion: vitest.fn(), getExtensionVersion: vitest.fn(),
getExtensionVersionRange: vitest.fn(), getExtensionVersionRange: vitest.fn(),
getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'), getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'),