From 0373e7f97d7ecb584d360d87a5d546b1f56f6541 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 18 Jun 2024 12:55:56 +0530 Subject: [PATCH] feat: add database health checks and remove legacy health check flag Breaking: The config.healthCheck is no longer relevant --- package.json | 60 +++--- src/connection/index.ts | 63 ------- src/database/checks/db_check.ts | 71 ++++++++ .../checks/db_connection_count_check.ts | 171 ++++++++++++++++++ src/types/database.ts | 6 - test/connection/connection.spec.ts | 25 --- test/database/db_check.spec.ts | 71 ++++++++ .../db_connection_count_check.spec.ts | 164 +++++++++++++++++ 8 files changed, 507 insertions(+), 124 deletions(-) create mode 100644 src/database/checks/db_check.ts create mode 100644 src/database/checks/db_connection_count_check.ts create mode 100644 test/database/db_check.spec.ts create mode 100644 test/database/db_connection_count_check.spec.ts diff --git a/package.json b/package.json index ccc0bdad..be8da19b 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "@adonisjs/lucid", - "version": "20.6.0", "description": "SQL ORM built on top of Active Record pattern", + "version": "20.6.0", "engines": { "node": ">=18.16.0" }, "main": "./build/index.js", + "type": "module", "files": [ "build/commands", "build/providers", @@ -17,7 +18,6 @@ "build/configure.d.ts", "build/configure.js" ], - "type": "module", "exports": { ".": "./build/index.js", "./schema": "./build/src/schema/main.js", @@ -57,25 +57,9 @@ "test": "c8 npm run test:docker", "index:commands": "adonis-kit index build/commands" }, - "dependencies": { - "@adonisjs/presets": "^2.4.1", - "@faker-js/faker": "^8.4.1", - "@poppinss/hooks": "^7.2.3", - "@poppinss/macroable": "^1.0.2", - "@poppinss/utils": "^6.7.3", - "fast-deep-equal": "^3.1.3", - "igniculus": "^1.5.0", - "kleur": "^4.1.5", - "knex": "^3.1.0", - "knex-dynamic-connection": "^3.1.1", - "pretty-hrtime": "^1.0.3", - "qs": "^6.12.1", - "slash": "^5.1.0", - "tarn": "^3.0.2" - }, "devDependencies": { "@adonisjs/assembler": "^7.7.0", - "@adonisjs/core": "^6.10.1", + "@adonisjs/core": "^6.11.0", "@adonisjs/eslint-config": "^1.3.0", "@adonisjs/prettier-config": "^1.3.0", "@adonisjs/tsconfig": "^1.3.0", @@ -84,15 +68,15 @@ "@japa/assert": "^3.0.0", "@japa/file-system": "^2.3.0", "@japa/runner": "^3.1.4", - "@swc/core": "^1.5.27", + "@swc/core": "^1.6.1", "@types/chance": "^1.1.6", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.2", + "@types/node": "^20.14.4", "@types/pretty-hrtime": "^1.0.3", "@types/qs": "^6.9.15", "@vinejs/vine": "^2.1.0", "better-sqlite3": "^11.0.0", - "c8": "^9.1.0", + "c8": "^10.1.2", "chance": "^1.1.11", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", @@ -103,16 +87,32 @@ "github-label-sync": "^2.3.1", "husky": "^9.0.11", "luxon": "^3.4.4", - "mysql2": "^3.10.0", + "mysql2": "^3.10.1", "np": "^10.0.5", "pg": "^8.12.0", - "prettier": "^3.3.1", + "prettier": "^3.3.2", "reflect-metadata": "^0.2.2", "sqlite3": "^5.1.7", "tedious": "^18.2.0", "ts-node": "^10.9.2", "typescript": "^5.4.5" }, + "dependencies": { + "@adonisjs/presets": "^2.4.1", + "@faker-js/faker": "^8.4.1", + "@poppinss/hooks": "^7.2.3", + "@poppinss/macroable": "^1.0.2", + "@poppinss/utils": "^6.7.3", + "fast-deep-equal": "^3.1.3", + "igniculus": "^1.5.0", + "kleur": "^4.1.5", + "knex": "^3.1.0", + "knex-dynamic-connection": "^3.2.0", + "pretty-hrtime": "^1.0.3", + "qs": "^6.12.1", + "slash": "^5.1.0", + "tarn": "^3.0.2" + }, "peerDependencies": { "@adonisjs/assembler": "^7.7.0", "@adonisjs/core": "^6.10.1", @@ -126,8 +126,8 @@ "optional": true } }, - "license": "MIT", "author": "virk,adonisjs", + "license": "MIT", "homepage": "https://github.com/adonisjs/lucid#readme", "repository": { "type": "git", @@ -140,6 +140,11 @@ "extends": "@adonisjs/eslint-config/package" }, "prettier": "@adonisjs/prettier-config", + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, "publishConfig": { "access": "public", "tag": "latest" @@ -150,11 +155,6 @@ "branch": "main", "anyBranch": false }, - "commitlint": { - "extends": [ - "@commitlint/config-conventional" - ] - }, "c8": { "reporter": [ "text", diff --git a/src/connection/index.ts b/src/connection/index.ts index e19ec13c..9d382582 100644 --- a/src/connection/index.ts +++ b/src/connection/index.ts @@ -12,7 +12,6 @@ import knex, { Knex } from 'knex' import { EventEmitter } from 'node:events' import { patchKnex } from 'knex-dynamic-connection' import type { Logger } from '@adonisjs/core/logger' -import { HealthCheckResult } from '@adonisjs/core/types/health' // @ts-expect-error import { resolveClientNameWithAliases } from 'knex/lib/util/helpers.js' import { ConnectionConfig, ConnectionContract } from '../types/database.js' @@ -256,68 +255,6 @@ export class Connection extends EventEmitter implements ConnectionContract { patchKnex(this.readClient, this.readConfigResolver.bind(this)) } - /** - * Checks all the read hosts by running a query on them. Stops - * after first error. - */ - private async checkReadHosts() { - const configCopy = Object.assign( - { log: new ConnectionLogger(this.name, this.logger) }, - this.config, - { - debug: false, - } - ) - let error: any = null - - // eslint-disable-next-line @typescript-eslint/naming-convention - for (let _ of this.readReplicas) { - configCopy.connection = this.readConfigResolver(this.config) - this.logger.trace({ connection: this.name }, 'spawing health check read connection') - const client = knex.knex(configCopy) - - try { - if (this.dialectName === 'oracledb') { - await client.raw('SELECT 1 + 1 AS result FROM dual') - } else { - await client.raw('SELECT 1 + 1 AS result') - } - } catch (err) { - error = err - } - - /** - * Cleanup client connection - */ - await client.destroy() - this.logger.trace({ connection: this.name }, 'destroying health check read connection') - - /** - * Return early when there is an error - */ - if (error) { - break - } - } - - return error - } - - /** - * Checks for the write host - */ - private async checkWriteHost() { - try { - if (this.dialectName === 'oracledb') { - await this.client!.raw('SELECT 1 + 1 AS result FROM dual') - } else { - await this.client!.raw('SELECT 1 + 1 AS result') - } - } catch (error) { - return error - } - } - /** * Returns the pool instance for the given connection */ diff --git a/src/database/checks/db_check.ts b/src/database/checks/db_check.ts new file mode 100644 index 00000000..50282602 --- /dev/null +++ b/src/database/checks/db_check.ts @@ -0,0 +1,71 @@ +/* + * @adonisjs/lucid + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { BaseCheck, Result } from '@adonisjs/core/health' +import type { HealthCheckResult } from '@adonisjs/core/types/health' +import type { QueryClientContract } from '../../types/database.js' + +/** + * The DbCheck attempts to establish the database connection by + * executing a sample query. + */ +export class DbCheck extends BaseCheck { + #client: QueryClientContract + + /** + * Health check public name + */ + name: string + + constructor(client: QueryClientContract) { + super() + this.#client = client + this.name = `Database health check (${client.connectionName})` + } + + /** + * Returns connection metadata to be shared in the health checks + * report + */ + #getConnectionMetadata() { + return { + connection: { + name: this.#client.connectionName, + dialect: this.#client.dialect.name, + }, + } + } + + /** + * Internal method to ping the database server + */ + async #ping() { + if (this.#client.dialect.name === 'oracledb') { + await this.#client.rawQuery('SELECT 1 + 1 AS result FROM dual') + } else { + await this.#client!.rawQuery('SELECT 1 + 1 AS result') + } + } + + /** + * Executes the health check + */ + async run(): Promise { + try { + await this.#ping() + return Result.ok('Successfully connected to the database server').mergeMetaData( + this.#getConnectionMetadata() + ) + } catch (error) { + return Result.failed(error.message || 'Connection failed', error).mergeMetaData( + this.#getConnectionMetadata() + ) + } + } +} diff --git a/src/database/checks/db_connection_count_check.ts b/src/database/checks/db_connection_count_check.ts new file mode 100644 index 00000000..d7f590ac --- /dev/null +++ b/src/database/checks/db_connection_count_check.ts @@ -0,0 +1,171 @@ +/* + * @adonisjs/lucid + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { BaseCheck, Result } from '@adonisjs/core/health' +import type { HealthCheckResult } from '@adonisjs/core/types/health' +import type { QueryClientContract } from '../../types/database.js' + +/** + * The DbConnectionCountCheck can be used to monitor the active + * database connections and report a warning or error after + * a certain threshold has been execeeded. + */ +export class DbConnectionCountCheck extends BaseCheck { + #client: QueryClientContract + + /** + * Method to compute the memory consumption + */ + #computeFn: (client: QueryClientContract) => Promise = async (client) => { + if (client.dialect.name === 'postgres') { + const response = await client.query().from('pg_stat_activity').count('* as connections') + return Number(response[0].connections) + } + + if (client.dialect.name === 'mysql') { + const response = await client + .query() + .from('information_schema.PROCESSLIST') + .count('* as connections') + return Number(response[0].connections) + } + + return null + } + + /** + * Connections count threshold after which a warning will be created + */ + #warnThreshold: number = 10 + + /** + * Connections count threshold after which an error will be created + */ + #failThreshold: number = 15 + + /** + * Health check public name + */ + name: string + + constructor(client: QueryClientContract) { + super() + this.#client = client + this.name = `Connection count health check (${client.connectionName})` + } + + /** + * Returns connection metadata to be shared in the health checks + * report + */ + #getConnectionMetadata() { + return { + connection: { + name: this.#client.connectionName, + dialect: this.#client.dialect.name, + }, + } + } + + /** + * Returns connections count metadata to be shared in the + * health checks report + */ + #getConnectionsCountMetadata(active: number) { + return { + connectionsCount: { + active, + warningThreshold: this.#warnThreshold, + failureThreshold: this.#failThreshold, + }, + } + } + + /** + * Define the connections count threshold after which a + * warning should be created. + * + * ``` + * .warnWhenExceeds(20) + * ``` + */ + warnWhenExceeds(connectionsCount: number) { + this.#warnThreshold = connectionsCount + return this + } + + /** + * Define the connections count threshold after which an + * error should be created. + * + * ``` + * .failWhenExceeds(30) + * ``` + */ + failWhenExceeds(connectionsCount: number) { + this.#failThreshold = connectionsCount + return this + } + + /** + * Define a custom callback to compute database connections count. + * The return value must be a number of active connections + * or null (if dialect is not supported). + */ + compute(callback: (client: QueryClientContract) => Promise): this { + this.#computeFn = callback + return this + } + + /** + * Executes the health check + */ + async run(): Promise { + try { + const connectionsCount = await this.#computeFn(this.#client) + if (!connectionsCount) { + return Result.ok( + `Check skipped. Unable to get active connections for ${this.#client.dialect.name} dialect` + ).mergeMetaData(this.#getConnectionMetadata()) + } + + /** + * Check if we have crossed the failure threshold + */ + if (connectionsCount > this.#failThreshold) { + return Result.failed( + `There are ${connectionsCount} active connections, which is above the threshold of ${this.#failThreshold} connections` + ) + .mergeMetaData(this.#getConnectionMetadata()) + .mergeMetaData(this.#getConnectionsCountMetadata(connectionsCount)) + } + + /** + * Check if we have crossed the warning threshold + */ + if (connectionsCount > this.#warnThreshold) { + return Result.warning( + `There are ${connectionsCount} active connections, which is above the threshold of ${this.#warnThreshold} connections` + ) + .mergeMetaData(this.#getConnectionMetadata()) + .mergeMetaData(this.#getConnectionsCountMetadata(connectionsCount)) + } + + return Result.ok( + `There are ${connectionsCount} active connections, which is under the defined thresholds` + ) + .mergeMetaData(this.#getConnectionMetadata()) + .mergeMetaData(this.#getConnectionsCountMetadata(connectionsCount)) + } catch (error) { + return Result.failed(error.message || 'Connection failed', error).mergeMetaData( + this.#getConnectionMetadata() + ) + } + } +} diff --git a/src/types/database.ts b/src/types/database.ts index 8fd837bf..b46b277a 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -12,7 +12,6 @@ import type { Pool } from 'tarn' import type { EventEmitter } from 'node:events' import type { ConnectionOptions } from 'node:tls' import type { Emitter } from '@adonisjs/core/events' -import type { HealthCheckResult } from '@adonisjs/core/types/health' import { LucidModel, ModelQueryBuilderContract } from './model.js' import { FromTable, @@ -694,11 +693,6 @@ export interface ConnectionContract extends EventEmitter { * Disconnect knex */ disconnect(): Promise - - /** - * Returns the connection report - */ - getReport(): Promise } /** diff --git a/test/connection/connection.spec.ts b/test/connection/connection.spec.ts index 274fc94d..1b709e78 100644 --- a/test/connection/connection.spec.ts +++ b/test/connection/connection.spec.ts @@ -10,7 +10,6 @@ import { Knex } from 'knex' import { test } from '@japa/runner' import { MysqlConfig } from '../../src/types/database.js' - import { Connection } from '../../src/connection/index.js' import { setup, cleanup, getConfig, resetTables, logger } from '../../test-helpers/index.js' @@ -164,27 +163,3 @@ if (process.env.DB === 'mysql') { }) }) } - -test.group('Health Checks', (group) => { - group.setup(async () => { - await setup() - }) - - group.teardown(async () => { - await cleanup() - }) - - test('get healthcheck report for healthy connection', async ({ assert }) => { - const connection = new Connection('primary', getConfig(), logger) - connection.connect() - - const report = await connection.getReport() - assert.deepEqual(report, { - connection: 'primary', - message: 'Connection is healthy', - error: null, - }) - - await connection.disconnect() - }) -}) diff --git a/test/database/db_check.spec.ts b/test/database/db_check.spec.ts new file mode 100644 index 00000000..e1369b44 --- /dev/null +++ b/test/database/db_check.spec.ts @@ -0,0 +1,71 @@ +/* + * @adonisjs/lucid + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' + +import { Database } from '../../src/database/main.js' +import { DbCheck } from '../../src/database/checks/db_check.js' +import { getConfig, setup, cleanup, logger, createEmitter } from '../../test-helpers/index.js' + +test.group('Db connection check', (group) => { + group.setup(async () => { + await setup() + }) + + group.teardown(async () => { + await cleanup() + }) + + test('perform health check for a connection', async ({ assert }) => { + const config = { + connection: 'primary', + connections: { primary: getConfig() }, + } + + const db = new Database(config, logger, createEmitter()) + + const healthCheck = new DbCheck(db.connection()) + const result = await healthCheck.run() + assert.containsSubset(result, { + message: 'Successfully connected to the database server', + status: 'ok', + meta: { connection: { name: 'primary', dialect: config.connections.primary.client } }, + }) + + await db.manager.closeAll() + }) + + test('report error when unable to connect', async ({ assert }) => { + const config = { + connection: 'primary', + connections: { + primary: { + client: 'mysql2' as const, + connection: { + host: 'localhost', + port: 3333, + }, + }, + }, + } + + const db = new Database(config, logger, createEmitter()) + + const healthCheck = new DbCheck(db.connection()) + const result = await healthCheck.run() + assert.containsSubset(result, { + message: 'Connection failed', + status: 'error', + meta: { connection: { name: 'primary', dialect: 'mysql' } }, + }) + assert.equal(result.meta?.error.code, 'ECONNREFUSED') + + await db.manager.closeAll() + }) +}) diff --git a/test/database/db_connection_count_check.spec.ts b/test/database/db_connection_count_check.spec.ts new file mode 100644 index 00000000..32e30939 --- /dev/null +++ b/test/database/db_connection_count_check.spec.ts @@ -0,0 +1,164 @@ +/* + * @adonisjs/lucid + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' + +import { Database } from '../../src/database/main.js' +import { getConfig, setup, cleanup, logger, createEmitter } from '../../test-helpers/index.js' +import { DbConnectionCountCheck } from '../../src/database/checks/db_connection_count_check.js' + +test.group('Db connection count check', (group) => { + group.setup(async () => { + await setup() + }) + + group.teardown(async () => { + await cleanup() + }) + + test('return error when failure threshold has been crossed', async ({ assert }) => { + const config = { + connection: 'primary', + connections: { primary: getConfig() }, + } + + const db = new Database(config, logger, createEmitter()) + + const healthCheck = new DbConnectionCountCheck(db.connection()).compute(async () => { + return 20 + }) + + const result = await healthCheck.run() + assert.containsSubset(result, { + message: 'There are 20 active connections, which is above the threshold of 15 connections', + status: 'error', + meta: { + connection: { name: 'primary', dialect: config.connections.primary.client }, + connectionsCount: { + active: 20, + failureThreshold: 15, + warningThreshold: 10, + }, + }, + }) + + await db.manager.closeAll() + }) + + test('return warning when warning threshold has been crossed', async ({ assert }) => { + const config = { + connection: 'primary', + connections: { primary: getConfig() }, + } + + const db = new Database(config, logger, createEmitter()) + + const healthCheck = new DbConnectionCountCheck(db.connection()).compute(async () => { + return 12 + }) + + const result = await healthCheck.run() + assert.containsSubset(result, { + message: 'There are 12 active connections, which is above the threshold of 10 connections', + status: 'warning', + meta: { + connection: { name: 'primary', dialect: config.connections.primary.client }, + connectionsCount: { + active: 12, + failureThreshold: 15, + warningThreshold: 10, + }, + }, + }) + + await db.manager.closeAll() + }) + + test('return success when unable to compute connections count', async ({ assert }) => { + const config = { + connection: 'primary', + connections: { primary: getConfig() }, + } + + const db = new Database(config, logger, createEmitter()) + + const healthCheck = new DbConnectionCountCheck(db.connection()).compute(async () => { + return null + }) + + const result = await healthCheck.run() + assert.containsSubset(result, { + message: `Check skipped. Unable to get active connections for ${config.connections.primary.client} dialect`, + status: 'ok', + meta: { + connection: { name: 'primary', dialect: config.connections.primary.client }, + }, + }) + + await db.manager.closeAll() + }) + + test('get PostgreSQL connections count', async ({ assert }) => { + const config = { + connection: 'primary', + connections: { primary: getConfig() }, + } + + const db = new Database(config, logger, createEmitter()) + + const healthCheck = new DbConnectionCountCheck(db.connection()) + + const result = await healthCheck.run() + const activeConnections = result.meta?.connectionsCount.active + + assert.containsSubset(result, { + message: `There are ${activeConnections} active connections, which is under the defined thresholds`, + status: 'ok', + meta: { + connection: { name: 'primary', dialect: db.connection().dialect.name }, + connectionsCount: { + active: activeConnections, + failureThreshold: 15, + warningThreshold: 10, + }, + }, + }) + + await db.manager.closeAll() + }).skip(process.env.DB !== 'pg', 'Only for PostgreSQL') + + test('get MySQL connections count', async ({ assert }) => { + const config = { + connection: 'primary', + connections: { primary: getConfig() }, + } + + const db = new Database(config, logger, createEmitter()) + + const healthCheck = new DbConnectionCountCheck(db.connection()) + + const result = await healthCheck.run() + const activeConnections = result.meta?.connectionsCount.active + + assert.containsSubset(result, { + message: `There are ${activeConnections} active connections, which is under the defined thresholds`, + status: 'ok', + meta: { + connection: { name: 'primary', dialect: db.connection().dialect.name }, + connectionsCount: { + active: activeConnections, + failureThreshold: 15, + warningThreshold: 10, + }, + }, + }) + + await db.manager.closeAll() + }).skip(!['mysql', 'mysql_legacy'].includes(process.env.DB!), 'Only for MySQL') +})