From 2e3dff6abc6c59c4291c80b6c0bf3759f6b9f311 Mon Sep 17 00:00:00 2001 From: Georgy Malkov Date: Fri, 11 Sep 2026 15:50:28 +0300 Subject: [PATCH 1/5] feat: add database healthcheck callback --- README.md | 17 ++++++++ lib/core.ts | 17 ++++++-- lib/dispatcher.ts | 63 +++++++++++++++++++++++++---- lib/types.ts | 24 +++++++++++ tests/dispatcher.test.js | 86 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 195 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ba91c1e..b2589d7 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,23 @@ export const {db, CoreBaseModel, helpers} = initDB({ - `topologyMode`: Connection topology, either `primary-replica` (the default) or `proxy` - `knexOptions`: Non-required additional options that will be passed to Knex before initialization - `onKnexCreated`: Optional callback called synchronously for each Knex instance created by the dispatcher, before database health checks start. Use it to attach instrumentation, event listeners, or plugins that do not require an active database connection. If it throws, initialization fails. The callback receives the Knex instance and returns nothing. +- `onHealthcheck`: Optional callback called after each database health check with a snapshot of every host's availability, latency, and role. It is called even when `suppressStatusLogs` is enabled. Callback errors do not interrupt database routing or subsequent health checks. + +```typescript +initDB({ + connectionString: process.env.POSTGRES_DSN_LIST, + onHealthcheck(status) { + for (const connection of status.connections) { + console.log(connection.host, connection.healthy, connection.latency); + } + if (status.topologyMode === 'primary-replica') { + for (const connection of status.connections) { + console.log(connection.primary ? 'primary' : 'replica'); + } + } + }, +}); +``` When all connection strings point to equivalent proxy or router instances, such as SPQR routers, use `proxy` mode. Healthy endpoints are then eligible for both primary and replica queries, and the endpoint with the lowest latest health-check latency is selected: diff --git a/lib/core.ts b/lib/core.ts index a87b7d0..6ec6d91 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -4,9 +4,17 @@ import {type Constructor, Model} from 'objection'; import {defaultDispatcherOptions, defaultExLogger, defaultKnexOptions} from './constants'; import {PGDispatcher} from './dispatcher'; -import type {BaseModel, ExLogger, TopologyMode} from './types'; - -export type {TopologyMode} from './types'; +import type {BaseModel, ExLogger, PGHealthcheckHandler, TopologyMode} from './types'; + +export type { + PGConnectionStatus, + PGHealthcheckHandler, + PGHealthcheckStatus, + PGPrimaryReplicaConnectionStatus, + PGPrimaryReplicaHealthcheckStatus, + PGProxyHealthcheckStatus, + TopologyMode, +} from './types'; export interface CoreDBDispatcherOptions { healthcheckInterval?: number; @@ -25,6 +33,7 @@ export interface CoreDBConstructorArgs { logger?: ExLogger; modelParams?: GetModelParams; onKnexCreated?: (knex: Knex) => void; + onHealthcheck?: PGHealthcheckHandler; } export function getModel(params: GetModelParams = {}): typeof BaseModel { @@ -89,6 +98,7 @@ export function initDB({ logger = defaultExLogger, modelParams, onKnexCreated, + onHealthcheck, }: CoreDBConstructorArgs) { if (!connectionString) { throw new Error('Empty connection string'); @@ -102,6 +112,7 @@ export function initDB({ knexOptions: mergedKnexOptions, logger, onKnexCreated, + onHealthcheck, }); const terminate = () => { diff --git a/lib/dispatcher.ts b/lib/dispatcher.ts index 38cf1ab..b8f18b4 100644 --- a/lib/dispatcher.ts +++ b/lib/dispatcher.ts @@ -3,7 +3,7 @@ import {URL} from 'url'; import knexBuilder from 'knex'; import type {Knex} from 'knex'; -import type {Dict, ExLogger, PDOptions} from './types'; +import type {Dict, ExLogger, PDOptions, PGHealthcheckHandler, PGHealthcheckStatus} from './types'; import Timeout = NodeJS.Timer; @@ -26,6 +26,7 @@ export interface PDConstructorArgs { logger: ExLogger; onKnexCreated?: (knex: Knex) => void; + onHealthcheck?: PGHealthcheckHandler; } interface PDConnection { @@ -51,6 +52,7 @@ export class PGDispatcher { private connections: PDConnection[]; private options: PDOptions; private logger: {info: InfoLogger; error: ErrorLogger}; + private onHealthcheck?: PGHealthcheckHandler; private hcTimer?: Timeout | null; private isInit = false; @@ -60,6 +62,7 @@ export class PGDispatcher { knexOptions = {}, logger, onKnexCreated, + onHealthcheck, }: PDConstructorArgs) { if (!connections.length) { throw new Error('Empty connections list is not allowed'); @@ -83,6 +86,7 @@ export class PGDispatcher { }); this.options = options; this.knexOptions = knexOptions; + this.onHealthcheck = onHealthcheck; this.logger = { info: ({message, data}) => { @@ -209,18 +213,17 @@ export class PGDispatcher { }), ); Promise.all(checkups).then(() => { + const status = this.getHealthcheckStatus(); this.logger.info({ message: 'Database current status', data: { - ...(this.isProxyMode ? {topologyMode: this.options.topologyMode} : {}), - connections: this.connections.map((c) => ({ - host: c.host, - ...(this.isProxyMode ? {} : {primary: c.primary}), - healthy: c.healthy, - latency: c.latency, - })), + ...(status.topologyMode === 'proxy' + ? {topologyMode: status.topologyMode} + : {}), + connections: status.connections, }, }); + this.notifyHealthcheck(status); }); }; @@ -299,6 +302,50 @@ export class PGDispatcher { await Promise.all(this.connections.map((c) => c.knex)); } + private getHealthcheckStatus(): PGHealthcheckStatus { + if (this.isProxyMode) { + return { + topologyMode: 'proxy', + connections: this.connections.map((connection) => ({ + host: connection.host, + healthy: connection.healthy, + latency: connection.latency, + })), + }; + } + + return { + topologyMode: 'primary-replica', + connections: this.connections.map((connection) => ({ + host: connection.host, + primary: connection.primary, + healthy: connection.healthy, + latency: connection.latency, + })), + }; + } + + private notifyHealthcheck(status: PGHealthcheckStatus) { + if (!this.onHealthcheck) { + return; + } + + try { + Promise.resolve(this.onHealthcheck(status)).catch((error) => { + this.reportHealthcheckCallbackError(error); + }); + } catch (error) { + this.reportHealthcheckCallbackError(error); + } + } + + private reportHealthcheckCallbackError(error: unknown) { + this.logger.error({ + message: 'Database healthcheck callback failed', + error: error as Error, + }); + } + private get healthyConnections() { return this.connections.filter((c) => c.healthy); } diff --git a/lib/types.ts b/lib/types.ts index 5aee243..b467ebd 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -3,6 +3,30 @@ import type {PGDispatcher} from './dispatcher'; export type TopologyMode = 'primary-replica' | 'proxy'; +export interface PGConnectionStatus { + readonly host: string; + readonly healthy: boolean; + readonly latency: number; +} + +export interface PGPrimaryReplicaConnectionStatus extends PGConnectionStatus { + readonly primary: boolean; +} + +export interface PGPrimaryReplicaHealthcheckStatus { + readonly topologyMode: 'primary-replica'; + readonly connections: readonly PGPrimaryReplicaConnectionStatus[]; +} + +export interface PGProxyHealthcheckStatus { + readonly topologyMode: 'proxy'; + readonly connections: readonly PGConnectionStatus[]; +} + +export type PGHealthcheckStatus = PGPrimaryReplicaHealthcheckStatus | PGProxyHealthcheckStatus; + +export type PGHealthcheckHandler = (status: PGHealthcheckStatus) => void | Promise; + export interface PDOptions { healthcheckInterval: number; healthcheckTimeout: number; diff --git a/tests/dispatcher.test.js b/tests/dispatcher.test.js index 1518600..e17565a 100644 --- a/tests/dispatcher.test.js +++ b/tests/dispatcher.test.js @@ -30,7 +30,7 @@ function failedCheckup() { return Promise.reject(new Error('Proxy unavailable')); } -function createDispatcher(clients, options = {}) { +function createDispatcher(clients, options = {}, onHealthcheck) { clients.forEach((client) => knexBuilder.mockImplementationOnce(() => client)); const logger = { @@ -48,6 +48,7 @@ function createDispatcher(clients, options = {}) { healthcheckTimeout: 100, ...options, }, + onHealthcheck, }); activeDispatchers.push(dispatcher); @@ -145,3 +146,86 @@ describe('PGDispatcher topology modes', () => { } }); }); + +describe('PGDispatcher healthcheck callback', () => { + test('reports a snapshot for every primary and replica connection', async () => { + const onHealthcheck = jest.fn(); + const primary = createKnex(successfulCheckup({pg_is_in_recovery: false})); + const replica = createKnex(successfulCheckup({pg_is_in_recovery: true})); + const unavailable = createKnex(failedCheckup); + const {dispatcher} = createDispatcher([primary, replica, unavailable], {}, onHealthcheck); + + await dispatcher.ready(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onHealthcheck).toHaveBeenCalledWith({ + topologyMode: 'primary-replica', + connections: [ + { + host: 'database-0.example', + primary: true, + healthy: true, + latency: expect.any(Number), + }, + { + host: 'database-1.example', + primary: false, + healthy: true, + latency: expect.any(Number), + }, + { + host: 'database-2.example', + primary: false, + healthy: false, + latency: expect.any(Number), + }, + ], + }); + }); + + test('reports proxy status without primary/replica roles when status logs are suppressed', async () => { + const onHealthcheck = jest.fn(); + const {dispatcher, logger} = createDispatcher( + [createKnex(successfulCheckup({value: 1}))], + {suppressStatusLogs: true, topologyMode: 'proxy'}, + onHealthcheck, + ); + + await dispatcher.ready(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(logger.info).not.toHaveBeenCalled(); + expect(onHealthcheck).toHaveBeenCalledWith({ + topologyMode: 'proxy', + connections: [ + { + host: 'database-0.example', + healthy: true, + latency: expect.any(Number), + }, + ], + }); + expect(onHealthcheck.mock.calls[0][0].connections[0]).not.toHaveProperty('primary'); + }); + + test.each([ + [ + 'synchronous', + (error) => () => { + throw error; + }, + ], + ['asynchronous', (error) => () => Promise.reject(error)], + ])('isolates %s callback errors from database routing', async (_type, createCallback) => { + const callbackError = new Error('Healthcheck consumer failed'); + const onHealthcheck = jest.fn(createCallback(callbackError)); + const primary = createKnex(successfulCheckup({pg_is_in_recovery: false})); + const {dispatcher, logger} = createDispatcher([primary], {}, onHealthcheck); + + await dispatcher.ready(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(dispatcher.primary).toBe(primary); + expect(logger.error).toHaveBeenCalledWith('PGDispatcher error', callbackError, undefined); + }); +}); From 4b6d94bd2ab665c43a43a8f4a8ea7b9562de18d9 Mon Sep 17 00:00:00 2001 From: Georgy Malkov Date: Fri, 11 Sep 2026 16:22:58 +0300 Subject: [PATCH 2/5] docs: simplify healthcheck callback description --- README.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/README.md b/README.md index b2589d7..ef42bfa 100644 --- a/README.md +++ b/README.md @@ -68,22 +68,6 @@ export const {db, CoreBaseModel, helpers} = initDB({ - `onKnexCreated`: Optional callback called synchronously for each Knex instance created by the dispatcher, before database health checks start. Use it to attach instrumentation, event listeners, or plugins that do not require an active database connection. If it throws, initialization fails. The callback receives the Knex instance and returns nothing. - `onHealthcheck`: Optional callback called after each database health check with a snapshot of every host's availability, latency, and role. It is called even when `suppressStatusLogs` is enabled. Callback errors do not interrupt database routing or subsequent health checks. -```typescript -initDB({ - connectionString: process.env.POSTGRES_DSN_LIST, - onHealthcheck(status) { - for (const connection of status.connections) { - console.log(connection.host, connection.healthy, connection.latency); - } - if (status.topologyMode === 'primary-replica') { - for (const connection of status.connections) { - console.log(connection.primary ? 'primary' : 'replica'); - } - } - }, -}); -``` - When all connection strings point to equivalent proxy or router instances, such as SPQR routers, use `proxy` mode. Healthy endpoints are then eligible for both primary and replica queries, and the endpoint with the lowest latest health-check latency is selected: ```typescript From 9bfd935c3911792139d0d3bdb244bc9b4aae21fe Mon Sep 17 00:00:00 2001 From: Georgy Malkov Date: Fri, 11 Sep 2026 16:50:14 +0300 Subject: [PATCH 3/5] refactor: narrow healthcheck topology directly --- lib/dispatcher.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/dispatcher.ts b/lib/dispatcher.ts index b8f18b4..506e400 100644 --- a/lib/dispatcher.ts +++ b/lib/dispatcher.ts @@ -303,9 +303,11 @@ export class PGDispatcher { } private getHealthcheckStatus(): PGHealthcheckStatus { - if (this.isProxyMode) { + const topologyMode = this.options.topologyMode; + + if (topologyMode === 'proxy') { return { - topologyMode: 'proxy', + topologyMode, connections: this.connections.map((connection) => ({ host: connection.host, healthy: connection.healthy, @@ -315,7 +317,7 @@ export class PGDispatcher { } return { - topologyMode: 'primary-replica', + topologyMode, connections: this.connections.map((connection) => ({ host: connection.host, primary: connection.primary, From d232fe66d8c26d6614a4e6dc4a396e50da5cdb5e Mon Sep 17 00:00:00 2001 From: Georgy Malkov Date: Fri, 11 Sep 2026 16:53:54 +0300 Subject: [PATCH 4/5] refactor: use proxy mode predicate for status --- lib/dispatcher.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/dispatcher.ts b/lib/dispatcher.ts index 506e400..99e9b10 100644 --- a/lib/dispatcher.ts +++ b/lib/dispatcher.ts @@ -217,9 +217,7 @@ export class PGDispatcher { this.logger.info({ message: 'Database current status', data: { - ...(status.topologyMode === 'proxy' - ? {topologyMode: status.topologyMode} - : {}), + ...(this.isProxyMode ? {topologyMode: this.options.topologyMode} : {}), connections: status.connections, }, }); @@ -303,11 +301,9 @@ export class PGDispatcher { } private getHealthcheckStatus(): PGHealthcheckStatus { - const topologyMode = this.options.topologyMode; - - if (topologyMode === 'proxy') { + if (this.isProxyMode) { return { - topologyMode, + topologyMode: 'proxy', connections: this.connections.map((connection) => ({ host: connection.host, healthy: connection.healthy, @@ -317,7 +313,7 @@ export class PGDispatcher { } return { - topologyMode, + topologyMode: 'primary-replica', connections: this.connections.map((connection) => ({ host: connection.host, primary: connection.primary, From 7e244d1a18a1bab6181b2ad7e3d158562fc62b41 Mon Sep 17 00:00:00 2001 From: Georgy Malkov Date: Fri, 11 Sep 2026 17:06:56 +0300 Subject: [PATCH 5/5] fix: refine healthcheck callback semantics --- README.md | 2 +- lib/core.ts | 1 + lib/dispatcher.ts | 40 +++++++++++++++++++++++------ lib/types.ts | 6 +++-- tests/dispatcher.test.js | 54 +++++++++++++++++++++++++++++----------- 5 files changed, 78 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ef42bfa..b33f8ca 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ export const {db, CoreBaseModel, helpers} = initDB({ - `topologyMode`: Connection topology, either `primary-replica` (the default) or `proxy` - `knexOptions`: Non-required additional options that will be passed to Knex before initialization - `onKnexCreated`: Optional callback called synchronously for each Knex instance created by the dispatcher, before database health checks start. Use it to attach instrumentation, event listeners, or plugins that do not require an active database connection. If it throws, initialization fails. The callback receives the Knex instance and returns nothing. -- `onHealthcheck`: Optional callback called after each database health check with a snapshot of every host's availability, latency, and role. It is called even when `suppressStatusLogs` is enabled. Callback errors do not interrupt database routing or subsequent health checks. +- `onHealthcheck`: Optional synchronous callback called after each database health check with a snapshot of every host's availability, latency, and role. In `primary-replica` mode, unavailable hosts have the `unknown` role; proxy connections do not include a role. It is called even when `suppressStatusLogs` is enabled. Callback errors do not interrupt database routing or subsequent health checks. When all connection strings point to equivalent proxy or router instances, such as SPQR routers, use `proxy` mode. Healthy endpoints are then eligible for both primary and replica queries, and the endpoint with the lowest latest health-check latency is selected: diff --git a/lib/core.ts b/lib/core.ts index 6ec6d91..a363b99 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -7,6 +7,7 @@ import {PGDispatcher} from './dispatcher'; import type {BaseModel, ExLogger, PGHealthcheckHandler, TopologyMode} from './types'; export type { + PGConnectionRole, PGConnectionStatus, PGHealthcheckHandler, PGHealthcheckStatus, diff --git a/lib/dispatcher.ts b/lib/dispatcher.ts index 99e9b10..a1b8221 100644 --- a/lib/dispatcher.ts +++ b/lib/dispatcher.ts @@ -3,7 +3,14 @@ import {URL} from 'url'; import knexBuilder from 'knex'; import type {Knex} from 'knex'; -import type {Dict, ExLogger, PDOptions, PGHealthcheckHandler, PGHealthcheckStatus} from './types'; +import type { + Dict, + ExLogger, + PDOptions, + PGConnectionRole, + PGHealthcheckHandler, + PGHealthcheckStatus, +} from './types'; import Timeout = NodeJS.Timer; @@ -55,6 +62,7 @@ export class PGDispatcher { private onHealthcheck?: PGHealthcheckHandler; private hcTimer?: Timeout | null; private isInit = false; + private isTerminating = false; constructor({ connections = [], @@ -124,6 +132,8 @@ export class PGDispatcher { } terminate() { + this.isTerminating = true; + if (this.hcTimer) { clearInterval(this.hcTimer); } @@ -204,6 +214,10 @@ export class PGDispatcher { private async initHealthcheck() { await this.knexReady(); + if (this.isTerminating) { + return; + } + const performHealthcheck = () => { const checkups = this.connections.map((connection) => this.checkDatabase(connection).catch((error) => { @@ -213,12 +227,18 @@ export class PGDispatcher { }), ); Promise.all(checkups).then(() => { + // Connections hold shared current state; this is not an isolated per-cycle result. const status = this.getHealthcheckStatus(); this.logger.info({ message: 'Database current status', data: { ...(this.isProxyMode ? {topologyMode: this.options.topologyMode} : {}), - connections: status.connections, + connections: this.connections.map((connection) => ({ + host: connection.host, + ...(this.isProxyMode ? {} : {primary: connection.primary}), + healthy: connection.healthy, + latency: connection.latency, + })), }, }); this.notifyHealthcheck(status); @@ -316,22 +336,28 @@ export class PGDispatcher { topologyMode: 'primary-replica', connections: this.connections.map((connection) => ({ host: connection.host, - primary: connection.primary, + role: this.getConnectionRole(connection), healthy: connection.healthy, latency: connection.latency, })), }; } + private getConnectionRole(connection: PDConnection): PGConnectionRole { + if (!connection.healthy) { + return 'unknown'; + } + + return connection.primary ? 'primary' : 'replica'; + } + private notifyHealthcheck(status: PGHealthcheckStatus) { - if (!this.onHealthcheck) { + if (!this.onHealthcheck || this.isTerminating) { return; } try { - Promise.resolve(this.onHealthcheck(status)).catch((error) => { - this.reportHealthcheckCallbackError(error); - }); + this.onHealthcheck(status); } catch (error) { this.reportHealthcheckCallbackError(error); } diff --git a/lib/types.ts b/lib/types.ts index b467ebd..99becfc 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -9,8 +9,10 @@ export interface PGConnectionStatus { readonly latency: number; } +export type PGConnectionRole = 'primary' | 'replica' | 'unknown'; + export interface PGPrimaryReplicaConnectionStatus extends PGConnectionStatus { - readonly primary: boolean; + readonly role: PGConnectionRole; } export interface PGPrimaryReplicaHealthcheckStatus { @@ -25,7 +27,7 @@ export interface PGProxyHealthcheckStatus { export type PGHealthcheckStatus = PGPrimaryReplicaHealthcheckStatus | PGProxyHealthcheckStatus; -export type PGHealthcheckHandler = (status: PGHealthcheckStatus) => void | Promise; +export type PGHealthcheckHandler = (status: PGHealthcheckStatus) => void; export interface PDOptions { healthcheckInterval: number; diff --git a/tests/dispatcher.test.js b/tests/dispatcher.test.js index e17565a..c90ebe4 100644 --- a/tests/dispatcher.test.js +++ b/tests/dispatcher.test.js @@ -153,7 +153,11 @@ describe('PGDispatcher healthcheck callback', () => { const primary = createKnex(successfulCheckup({pg_is_in_recovery: false})); const replica = createKnex(successfulCheckup({pg_is_in_recovery: true})); const unavailable = createKnex(failedCheckup); - const {dispatcher} = createDispatcher([primary, replica, unavailable], {}, onHealthcheck); + const {dispatcher, logger} = createDispatcher( + [primary, replica, unavailable], + {}, + onHealthcheck, + ); await dispatcher.ready(); await new Promise((resolve) => setImmediate(resolve)); @@ -163,24 +167,33 @@ describe('PGDispatcher healthcheck callback', () => { connections: [ { host: 'database-0.example', - primary: true, + role: 'primary', healthy: true, latency: expect.any(Number), }, { host: 'database-1.example', - primary: false, + role: 'replica', healthy: true, latency: expect.any(Number), }, { host: 'database-2.example', - primary: false, + role: 'unknown', healthy: false, latency: expect.any(Number), }, ], }); + + const statusLog = logger.info.mock.calls.find( + ([message]) => message === 'Database current status', + ); + expect(statusLog[1].connections).toEqual([ + expect.objectContaining({host: 'database-0.example', primary: true}), + expect.objectContaining({host: 'database-1.example', primary: false}), + expect.objectContaining({host: 'database-2.example', primary: false}), + ]); }); test('reports proxy status without primary/replica roles when status logs are suppressed', async () => { @@ -205,20 +218,14 @@ describe('PGDispatcher healthcheck callback', () => { }, ], }); - expect(onHealthcheck.mock.calls[0][0].connections[0]).not.toHaveProperty('primary'); + expect(onHealthcheck.mock.calls[0][0].connections[0]).not.toHaveProperty('role'); }); - test.each([ - [ - 'synchronous', - (error) => () => { - throw error; - }, - ], - ['asynchronous', (error) => () => Promise.reject(error)], - ])('isolates %s callback errors from database routing', async (_type, createCallback) => { + test('isolates callback errors from database routing', async () => { const callbackError = new Error('Healthcheck consumer failed'); - const onHealthcheck = jest.fn(createCallback(callbackError)); + const onHealthcheck = jest.fn(() => { + throw callbackError; + }); const primary = createKnex(successfulCheckup({pg_is_in_recovery: false})); const {dispatcher, logger} = createDispatcher([primary], {}, onHealthcheck); @@ -228,4 +235,21 @@ describe('PGDispatcher healthcheck callback', () => { expect(dispatcher.primary).toBe(primary); expect(logger.error).toHaveBeenCalledWith('PGDispatcher error', callbackError, undefined); }); + + test('does not notify after termination starts', async () => { + let finishCheckup; + const checkup = () => + new Promise((resolve) => { + finishCheckup = resolve; + }); + const onHealthcheck = jest.fn(); + const {dispatcher} = createDispatcher([createKnex(checkup)], {}, onHealthcheck); + + await new Promise((resolve) => setImmediate(resolve)); + await dispatcher.terminate(); + finishCheckup({rows: [{pg_is_in_recovery: false}]}); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onHealthcheck).not.toHaveBeenCalled(); + }); });