Skip to content

Commit 12b65e3

Browse files
authored
fix(server): auto-reconnect to database (#12320)
1 parent 1783dfd commit 12b65e3

10 files changed

+130
-46
lines changed

Diff for: server/src/app.module.ts

+14-3
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,19 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
1818
import { AuthGuard } from 'src/middleware/auth.guard';
1919
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
2020
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
21-
import { HttpExceptionFilter } from 'src/middleware/http-exception.filter';
21+
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
2222
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
2323
import { repositories } from 'src/repositories';
2424
import { services } from 'src/services';
25+
import { DatabaseService } from 'src/services/database.service';
2526
import { setupEventHandlers } from 'src/utils/events';
2627
import { otelConfig } from 'src/utils/instrumentation';
2728

2829
const common = [...services, ...repositories];
2930

3031
const middleware = [
3132
FileUploadInterceptor,
32-
{ provide: APP_FILTER, useClass: HttpExceptionFilter },
33+
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
3334
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
3435
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
3536
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
@@ -43,7 +44,17 @@ const imports = [
4344
ConfigModule.forRoot(immichAppConfig),
4445
EventEmitterModule.forRoot(),
4546
OpenTelemetryModule.forRoot(otelConfig),
46-
TypeOrmModule.forRoot(databaseConfig),
47+
TypeOrmModule.forRootAsync({
48+
inject: [ModuleRef],
49+
useFactory: (moduleRef: ModuleRef) => {
50+
return {
51+
...databaseConfig,
52+
poolErrorHandler: (error) => {
53+
moduleRef.get(DatabaseService, { strict: false }).handleConnectionError(error);
54+
},
55+
};
56+
},
57+
}),
4758
TypeOrmModule.forFeature(entities),
4859
];
4960

Diff for: server/src/interfaces/database.interface.ts

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface VectorUpdateResult {
4040
export const IDatabaseRepository = 'IDatabaseRepository';
4141

4242
export interface IDatabaseRepository {
43+
reconnect(): Promise<boolean>;
4344
getExtensionVersion(extension: DatabaseExtension): Promise<ExtensionVersion>;
4445
getExtensionVersionRange(extension: VectorExtension): string;
4546
getPostgresVersion(): Promise<string>;

Diff for: server/src/middleware/error.interceptor.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from '@nestjs/common';
1010
import { Observable, catchError, throwError } from 'rxjs';
1111
import { ILoggerRepository } from 'src/interfaces/logger.interface';
12+
import { logGlobalError } from 'src/utils/logger';
1213
import { routeToErrorMessage } from 'src/utils/misc';
1314

1415
@Injectable()
@@ -25,9 +26,10 @@ export class ErrorInterceptor implements NestInterceptor {
2526
return error;
2627
}
2728

28-
const errorMessage = routeToErrorMessage(context.getHandler().name);
29-
this.logger.error(errorMessage, error, error?.errors, error?.stack);
30-
return new InternalServerErrorException(errorMessage);
29+
logGlobalError(this.logger, error);
30+
31+
const message = routeToErrorMessage(context.getHandler().name);
32+
return new InternalServerErrorException(message);
3133
}),
3234
),
3335
);

Diff for: server/src/middleware/global-exception.filter.ts

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Inject } from '@nestjs/common';
2+
import { Response } from 'express';
3+
import { ClsService } from 'nestjs-cls';
4+
import { ILoggerRepository } from 'src/interfaces/logger.interface';
5+
import { logGlobalError } from 'src/utils/logger';
6+
7+
@Catch()
8+
export class GlobalExceptionFilter implements ExceptionFilter<Error> {
9+
constructor(
10+
@Inject(ILoggerRepository) private logger: ILoggerRepository,
11+
private cls: ClsService,
12+
) {
13+
this.logger.setContext(GlobalExceptionFilter.name);
14+
}
15+
16+
catch(error: Error, host: ArgumentsHost) {
17+
const ctx = host.switchToHttp();
18+
const response = ctx.getResponse<Response>();
19+
const { status, body } = this.fromError(error);
20+
if (!response.headersSent) {
21+
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
22+
}
23+
}
24+
25+
private fromError(error: Error) {
26+
logGlobalError(this.logger, error);
27+
28+
if (error instanceof HttpException) {
29+
const status = error.getStatus();
30+
let body = error.getResponse();
31+
32+
// unclear what circumstances would return a string
33+
if (typeof body === 'string') {
34+
body = { message: body };
35+
}
36+
37+
return { status, body };
38+
}
39+
40+
return {
41+
status: 500,
42+
body: {
43+
message: 'Internal server error',
44+
},
45+
};
46+
}
47+
}

Diff for: server/src/middleware/http-exception.filter.ts

-39
This file was deleted.

Diff for: server/src/repositories/database.repository.ts

+13
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ export class DatabaseRepository implements IDatabaseRepository {
3131
this.logger.setContext(DatabaseRepository.name);
3232
}
3333

34+
async reconnect() {
35+
try {
36+
if (this.dataSource.isInitialized) {
37+
await this.dataSource.destroy();
38+
}
39+
const { isInitialized } = await this.dataSource.initialize();
40+
return isInitialized;
41+
} catch (error) {
42+
this.logger.error(`Database connection failed: ${error}`);
43+
return false;
44+
}
45+
}
46+
3447
async getExtensionVersion(extension: DatabaseExtension): Promise<ExtensionVersion> {
3548
const [res]: ExtensionVersion[] = await this.dataSource.query(
3649
`SELECT default_version as "availableVersion", installed_version as "installedVersion"

Diff for: server/src/repositories/logger.repository.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-en
33
import { ClsService } from 'nestjs-cls';
44
import { LogLevel } from 'src/config';
55
import { ILoggerRepository } from 'src/interfaces/logger.interface';
6-
import { LogColor } from 'src/utils/logger-colors';
6+
import { LogColor } from 'src/utils/logger';
77

88
const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL];
99

Diff for: server/src/services/database.service.ts

+25
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Inject, Injectable } from '@nestjs/common';
2+
import { Duration } from 'luxon';
23
import semver from 'semver';
34
import { getVectorExtension } from 'src/database.config';
45
import { OnEmit } from 'src/decorators';
@@ -59,8 +60,12 @@ const messages = {
5960
If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`,
6061
};
6162

63+
const RETRY_DURATION = Duration.fromObject({ seconds: 5 });
64+
6265
@Injectable()
6366
export class DatabaseService {
67+
private reconnection?: NodeJS.Timeout;
68+
6469
constructor(
6570
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
6671
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@@ -117,6 +122,26 @@ export class DatabaseService {
117122
});
118123
}
119124

125+
handleConnectionError(error: Error) {
126+
if (this.reconnection) {
127+
return;
128+
}
129+
130+
this.logger.error(`Database disconnected: ${error}`);
131+
this.reconnection = setInterval(() => void this.reconnect(), RETRY_DURATION.toMillis());
132+
}
133+
134+
private async reconnect() {
135+
const isConnected = await this.databaseRepository.reconnect();
136+
if (isConnected) {
137+
this.logger.log('Database reconnected');
138+
clearInterval(this.reconnection);
139+
delete this.reconnection;
140+
} else {
141+
this.logger.warn(`Database connection failed, retrying in ${RETRY_DURATION.toHuman()}`);
142+
}
143+
}
144+
120145
private async createExtension(extension: DatabaseExtension) {
121146
try {
122147
await this.databaseRepository.createExtension(extension);

Diff for: server/src/utils/logger-colors.ts renamed to server/src/utils/logger.ts

+23
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { HttpException } from '@nestjs/common';
2+
import { ILoggerRepository } from 'src/interfaces/logger.interface';
3+
import { TypeORMError } from 'typeorm';
4+
15
type ColorTextFn = (text: string) => string;
26

37
const isColorAllowed = () => !process.env.NO_COLOR;
@@ -15,3 +19,22 @@ export const LogColor = {
1519
export const LogStyle = {
1620
bold: colorIfAllowed((text: string) => `\u001B[1m${text}\u001B[0m`),
1721
};
22+
23+
export const logGlobalError = (logger: ILoggerRepository, error: Error) => {
24+
if (error instanceof HttpException) {
25+
const status = error.getStatus();
26+
const response = error.getResponse();
27+
logger.debug(`HttpException(${status}): ${JSON.stringify(response)}`);
28+
return;
29+
}
30+
31+
if (error instanceof TypeORMError) {
32+
logger.error(`Database error: ${error}`);
33+
return;
34+
}
35+
36+
if (error instanceof Error) {
37+
logger.error(`Unknown error: ${error}`);
38+
return;
39+
}
40+
};

Diff for: server/test/repositories/database.repository.mock.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Mocked, vitest } from 'vitest';
33

44
export const newDatabaseRepositoryMock = (): Mocked<IDatabaseRepository> => {
55
return {
6+
reconnect: vitest.fn(),
67
getExtensionVersion: vitest.fn(),
78
getExtensionVersionRange: vitest.fn(),
89
getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'),

0 commit comments

Comments
 (0)