feat(core): Add config to override default database ping interval and default idle connection timeout (#15764)

This commit is contained in:
Guillaume Jacquart
2025-06-03 12:07:39 +02:00
committed by GitHub
parent 8201202a54
commit ac06610485
6 changed files with 59 additions and 3 deletions

View File

@@ -84,6 +84,10 @@ class PostgresConfig {
@Env('DB_POSTGRESDB_CONNECTION_TIMEOUT') @Env('DB_POSTGRESDB_CONNECTION_TIMEOUT')
connectionTimeoutMs: number = 20_000; connectionTimeoutMs: number = 20_000;
/** Postgres idle connection timeout (ms) */
@Env('DB_POSTGRESDB_IDLE_CONNECTION_TIMEOUT')
idleTimeoutMs: number = 30_000;
@Nested @Nested
ssl: PostgresSSLConfig; ssl: PostgresSSLConfig;
} }
@@ -158,6 +162,12 @@ export class DatabaseConfig {
@Env('DB_TABLE_PREFIX') @Env('DB_TABLE_PREFIX')
tablePrefix: string = ''; tablePrefix: string = '';
/**
* The interval in seconds to ping the database to check if the connection is still alive.
*/
@Env('DB_PING_INTERVAL_SECONDS')
pingIntervalSeconds: number = 2;
@Nested @Nested
logging: LoggingConfig; logging: LoggingConfig;

View File

@@ -62,6 +62,7 @@ describe('GlobalConfig', () => {
rejectUnauthorized: true, rejectUnauthorized: true,
}, },
user: 'postgres', user: 'postgres',
idleTimeoutMs: 30_000,
}, },
sqlite: { sqlite: {
database: 'database.sqlite', database: 'database.sqlite',
@@ -72,6 +73,7 @@ describe('GlobalConfig', () => {
tablePrefix: '', tablePrefix: '',
type: 'sqlite', type: 'sqlite',
isLegacySqlite: true, isLegacySqlite: true,
pingIntervalSeconds: 2,
}, },
credentials: { credentials: {
defaultName: 'My credentials', defaultName: 'My credentials',
@@ -323,7 +325,9 @@ describe('GlobalConfig', () => {
process.env = { process.env = {
DB_POSTGRESDB_HOST: 'some-host', DB_POSTGRESDB_HOST: 'some-host',
DB_POSTGRESDB_USER: 'n8n', DB_POSTGRESDB_USER: 'n8n',
DB_POSTGRESDB_IDLE_CONNECTION_TIMEOUT: '10000',
DB_TABLE_PREFIX: 'test_', DB_TABLE_PREFIX: 'test_',
DB_PING_INTERVAL_SECONDS: '2',
NODES_INCLUDE: '["n8n-nodes-base.hackerNews"]', NODES_INCLUDE: '["n8n-nodes-base.hackerNews"]',
DB_LOGGING_MAX_EXECUTION_TIME: '0', DB_LOGGING_MAX_EXECUTION_TIME: '0',
N8N_METRICS: 'TRUE', N8N_METRICS: 'TRUE',
@@ -339,10 +343,12 @@ describe('GlobalConfig', () => {
...defaultConfig.database.postgresdb, ...defaultConfig.database.postgresdb,
host: 'some-host', host: 'some-host',
user: 'n8n', user: 'n8n',
idleTimeoutMs: 10_000,
}, },
sqlite: defaultConfig.database.sqlite, sqlite: defaultConfig.database.sqlite,
tablePrefix: 'test_', tablePrefix: 'test_',
type: 'sqlite', type: 'sqlite',
pingIntervalSeconds: 2,
}, },
endpoints: { endpoints: {
...defaultConfig.endpoints, ...defaultConfig.endpoints,

View File

@@ -102,6 +102,7 @@ describe('DbConnectionOptions', () => {
key: '', key: '',
rejectUnauthorized: true, rejectUnauthorized: true,
}, },
idleTimeoutMs: 30000,
}; };
}); });
@@ -121,6 +122,9 @@ describe('DbConnectionOptions', () => {
migrations: postgresMigrations, migrations: postgresMigrations,
connectTimeoutMS: 20000, connectTimeoutMS: 20000,
ssl: false, ssl: false,
extra: {
idleTimeoutMillis: 30000,
},
}); });
}); });

View File

@@ -1,3 +1,4 @@
import type { DatabaseConfig } from '@n8n/config';
import type { Migration } from '@n8n/db'; import type { Migration } from '@n8n/db';
import * as migrationHelper from '@n8n/db'; import * as migrationHelper from '@n8n/db';
import { DataSource, type DataSourceOptions } from '@n8n/typeorm'; import { DataSource, type DataSourceOptions } from '@n8n/typeorm';
@@ -17,6 +18,7 @@ describe('DbConnection', () => {
let dbConnection: DbConnection; let dbConnection: DbConnection;
const migrations = [{ name: 'TestMigration1' }, { name: 'TestMigration2' }] as Migration[]; const migrations = [{ name: 'TestMigration1' }, { name: 'TestMigration2' }] as Migration[];
const errorReporter = mock<ErrorReporter>(); const errorReporter = mock<ErrorReporter>();
const databaseConfig = mock<DatabaseConfig>();
const dataSource = mockDeep<DataSource>({ options: { migrations } }); const dataSource = mockDeep<DataSource>({ options: { migrations } });
const connectionOptions = mockDeep<DbConnectionOptions>(); const connectionOptions = mockDeep<DbConnectionOptions>();
const postgresOptions: DataSourceOptions = { const postgresOptions: DataSourceOptions = {
@@ -35,7 +37,7 @@ describe('DbConnection', () => {
connectionOptions.getOptions.mockReturnValue(postgresOptions); connectionOptions.getOptions.mockReturnValue(postgresOptions);
(DataSource as jest.Mock) = jest.fn().mockImplementation(() => dataSource); (DataSource as jest.Mock) = jest.fn().mockImplementation(() => dataSource);
dbConnection = new DbConnection(errorReporter, connectionOptions); dbConnection = new DbConnection(errorReporter, connectionOptions, databaseConfig);
}); });
describe('init', () => { describe('init', () => {
@@ -174,5 +176,29 @@ describe('DbConnection', () => {
expect(dataSource.query).not.toHaveBeenCalled(); expect(dataSource.query).not.toHaveBeenCalled();
}); });
it('should execute ping on schedule', async () => {
jest.useFakeTimers();
try {
// ARRANGE
dbConnection = new DbConnection(
errorReporter,
connectionOptions,
mock<DatabaseConfig>({
pingIntervalSeconds: 1,
}),
);
const pingSpy = jest.spyOn(dbConnection as any, 'ping');
// @ts-expect-error private property
dbConnection.scheduleNextPing();
jest.advanceTimersByTime(1000);
expect(pingSpy).toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
}); });
}); });

View File

@@ -131,6 +131,9 @@ export class DbConnectionOptions {
migrations: postgresMigrations, migrations: postgresMigrations,
connectTimeoutMS: postgresConfig.connectionTimeoutMs, connectTimeoutMS: postgresConfig.connectionTimeoutMs,
ssl, ssl,
extra: {
idleTimeoutMillis: postgresConfig.idleTimeoutMs,
},
}; };
} }

View File

@@ -1,4 +1,5 @@
import { inTest } from '@n8n/backend-common'; import { inTest } from '@n8n/backend-common';
import { DatabaseConfig } from '@n8n/config';
import type { Migration } from '@n8n/db'; import type { Migration } from '@n8n/db';
import { wrapMigration } from '@n8n/db'; import { wrapMigration } from '@n8n/db';
import { Memoized } from '@n8n/decorators'; import { Memoized } from '@n8n/decorators';
@@ -7,6 +8,8 @@ import { DataSource } from '@n8n/typeorm';
import { ErrorReporter } from 'n8n-core'; import { ErrorReporter } from 'n8n-core';
import { DbConnectionTimeoutError, ensureError } from 'n8n-workflow'; import { DbConnectionTimeoutError, ensureError } from 'n8n-workflow';
import { Time } from '@/constants';
import { DbConnectionOptions } from './db-connection-options'; import { DbConnectionOptions } from './db-connection-options';
type ConnectionState = { type ConnectionState = {
@@ -28,6 +31,7 @@ export class DbConnection {
constructor( constructor(
private readonly errorReporter: ErrorReporter, private readonly errorReporter: ErrorReporter,
private readonly connectionOptions: DbConnectionOptions, private readonly connectionOptions: DbConnectionOptions,
private readonly databaseConfig: DatabaseConfig,
) { ) {
this.dataSource = new DataSource(this.options); this.dataSource = new DataSource(this.options);
Container.set(DataSource, this.dataSource); Container.set(DataSource, this.dataSource);
@@ -80,9 +84,12 @@ export class DbConnection {
} }
} }
/** Ping DB connection every 2 seconds */ /** Ping DB connection every `pingIntervalSeconds` seconds to check if it is still alive. */
private scheduleNextPing() { private scheduleNextPing() {
this.pingTimer = setTimeout(async () => await this.ping(), 2000); this.pingTimer = setTimeout(
async () => await this.ping(),
this.databaseConfig.pingIntervalSeconds * Time.seconds.toMilliseconds,
);
} }
private async ping() { private async ping() {