diff --git a/.changeset/young-pears-applaud.md b/.changeset/young-pears-applaud.md new file mode 100644 index 00000000..f4619431 --- /dev/null +++ b/.changeset/young-pears-applaud.md @@ -0,0 +1,6 @@ +--- +"@slonik/driver": minor +"slonik": minor +--- + +add minimumPoolSize option diff --git a/README.md b/README.md index bb28d44e..26c32e8d 100644 --- a/README.md +++ b/README.md @@ -621,7 +621,8 @@ createPool( * @property idleInTransactionSessionTimeout Timeout (in milliseconds) after which idle clients are closed. Use 'DISABLE_TIMEOUT' constant to disable the timeout. (Default: 60000) * @property idleTimeout Timeout (in milliseconds) after which idle clients are closed. Use 'DISABLE_TIMEOUT' constant to disable the timeout. (Default: 5000) * @property interceptors An array of [Slonik interceptors](https://github.com/gajus/slonik#interceptors). - * @property maximumPoolSize Do not allow more than this many connections. Use 'DISABLE_TIMEOUT' constant to disable the timeout. (Default: 10) + * @property maximumPoolSize Do not allow more than this many connections. (Default: 10) + * @property minimumPoolSize Ensure that at least this many connections are available in the pool. (Default: 0) * @property queryRetryLimit Number of times a query failing with Transaction Rollback class error, that doesn't belong to a transaction, is retried. (Default: 5) * @property ssl [tls.connect options](https://nodejs.org/api/tls.html#tlsconnectoptions-callback) * @property statementTimeout Timeout (in milliseconds) after which database is instructed to abort the query. Use 'DISABLE_TIMEOUT' constant to disable the timeout. (Default: 60000) @@ -638,6 +639,7 @@ type ClientConfiguration = { idleTimeout?: number | 'DISABLE_TIMEOUT', interceptors?: Interceptor[], maximumPoolSize?: number, + maximumPoolSize?: number, queryRetryLimit?: number, ssl?: Parameters[0], statementTimeout?: number | 'DISABLE_TIMEOUT', diff --git a/packages/driver/src/factories/createDriverFactory.ts b/packages/driver/src/factories/createDriverFactory.ts index 37e4a7fb..eaa27e1f 100644 --- a/packages/driver/src/factories/createDriverFactory.ts +++ b/packages/driver/src/factories/createDriverFactory.ts @@ -39,6 +39,7 @@ export type DriverConfiguration = { readonly idleInTransactionSessionTimeout: number | 'DISABLE_TIMEOUT'; readonly idleTimeout?: number | 'DISABLE_TIMEOUT'; readonly maximumPoolSize?: number; + readonly minimumPoolSize?: number; readonly resetConnection?: (connection: BasicConnection) => Promise; readonly ssl?: TlsConnectionOptions; readonly statementTimeout: number | 'DISABLE_TIMEOUT'; diff --git a/packages/slonik/src/factories/createClientConfiguration.ts b/packages/slonik/src/factories/createClientConfiguration.ts index d8e61b9e..ef5868f8 100644 --- a/packages/slonik/src/factories/createClientConfiguration.ts +++ b/packages/slonik/src/factories/createClientConfiguration.ts @@ -23,6 +23,7 @@ export const createClientConfiguration = ( idleTimeout: 5_000, interceptors: [], maximumPoolSize: 10, + minimumPoolSize: 0, queryRetryLimit: 5, resetConnection: ({ query }) => { return query(`DISCARD ALL`); @@ -39,6 +40,18 @@ export const createClientConfiguration = ( ); } + if (configuration.minimumPoolSize < 0) { + throw new InvalidConfigurationError( + 'minimumPoolSize must be equal to or greater than 0.', + ); + } + + if (configuration.maximumPoolSize < configuration.minimumPoolSize) { + throw new InvalidConfigurationError( + 'maximumPoolSize must be equal to or greater than minimumPoolSize.', + ); + } + if (!configuration.typeParsers || configuration.typeParsers === typeParsers) { configuration.typeParsers = createTypeParserPreset(); } diff --git a/packages/slonik/src/factories/createConnectionPool.ts b/packages/slonik/src/factories/createConnectionPool.ts index b8e0b85c..bd34a59c 100644 --- a/packages/slonik/src/factories/createConnectionPool.ts +++ b/packages/slonik/src/factories/createConnectionPool.ts @@ -58,12 +58,13 @@ export type ConnectionPool = { export const createConnectionPool = ({ driver, - poolSize = 1, + maximumPoolSize, + minimumPoolSize, }: { driver: Driver; - idleTimeout?: number; - // TODO rename to `maxPoolSize` - poolSize?: number; + idleTimeout: number; + maximumPoolSize: number; + minimumPoolSize: number; }): ConnectionPool => { // See test "waits for all connections to be established before attempting to terminate the pool" // for explanation of why `pendingConnections` is needed. @@ -162,6 +163,12 @@ export const createConnectionPool = ({ const waitingClient = waitingClients.shift(); + if (!isEnding && !isEnded && connections.length < minimumPoolSize) { + addConnection(); + + return; + } + if (!waitingClient) { return; } @@ -195,7 +202,7 @@ export const createConnectionPool = ({ return idleConnection; } - if (pendingConnections.length + connections.length < poolSize) { + if (pendingConnections.length + connections.length < maximumPoolSize) { const newConnection = await addConnection(); newConnection.acquire(); @@ -214,8 +221,9 @@ export const createConnectionPool = ({ logger.warn( { connections: connections.length, + maximumPoolSize, + minimumPoolSize, pendingConnections: pendingConnections.length, - poolSize, waitingClients: waitingClients.length, }, `connection pool full; client has been queued`, diff --git a/packages/slonik/src/factories/createPoolConfiguration.ts b/packages/slonik/src/factories/createPoolConfiguration.ts index edb4ef10..43234a41 100644 --- a/packages/slonik/src/factories/createPoolConfiguration.ts +++ b/packages/slonik/src/factories/createPoolConfiguration.ts @@ -4,8 +4,9 @@ import { Logger as log } from '../Logger'; import { type ClientConfiguration } from '../types'; type PoolConfiguration = { - idleTimeout?: number; - poolSize?: number; + idleTimeout: number; + maximumPoolSize: number; + minimumPoolSize: number; }; export const createPoolConfiguration = ( @@ -13,7 +14,8 @@ export const createPoolConfiguration = ( ): PoolConfiguration => { const poolConfiguration = { idleTimeout: 10_000, - poolSize: 10, + maximumPoolSize: 10, + minimumPoolSize: 0, }; if (clientConfiguration.idleTimeout !== 'DISABLE_TIMEOUT') { @@ -29,7 +31,11 @@ export const createPoolConfiguration = ( } if (clientConfiguration.maximumPoolSize) { - poolConfiguration.poolSize = clientConfiguration.maximumPoolSize; + poolConfiguration.maximumPoolSize = clientConfiguration.maximumPoolSize; + } + + if (clientConfiguration.minimumPoolSize) { + poolConfiguration.minimumPoolSize = clientConfiguration.minimumPoolSize; } return poolConfiguration; diff --git a/packages/slonik/src/helpers.test/createIntegrationTests.ts b/packages/slonik/src/helpers.test/createIntegrationTests.ts index b9b7d0ec..ddf5f4c9 100644 --- a/packages/slonik/src/helpers.test/createIntegrationTests.ts +++ b/packages/slonik/src/helpers.test/createIntegrationTests.ts @@ -1942,6 +1942,114 @@ export const createIntegrationTests = ( ); }); + test('removes connections from the pool after the idle timeout', async (t) => { + const pool = await createPool(t.context.dsn, { + driverFactory, + idleTimeout: 100, + }); + + t.deepEqual( + pool.state(), + { + acquiredConnections: 0, + idleConnections: 0, + pendingDestroyConnections: 0, + pendingReleaseConnections: 0, + state: 'ACTIVE', + waitingClients: 0, + }, + 'initial state', + ); + + await pool.query(sql.unsafe` + SELECT 1 + `); + + t.deepEqual( + pool.state(), + { + acquiredConnections: 0, + idleConnections: 1, + pendingDestroyConnections: 0, + pendingReleaseConnections: 0, + state: 'ACTIVE', + waitingClients: 0, + }, + 'shows idle clients', + ); + + await delay(100); + + t.deepEqual( + pool.state(), + { + acquiredConnections: 0, + idleConnections: 0, + pendingDestroyConnections: 0, + pendingReleaseConnections: 0, + state: 'ACTIVE', + waitingClients: 0, + }, + 'shows no idle clients', + ); + }); + + test('retains a minimum number of connections in the pool', async (t) => { + const pool = await createPool(t.context.dsn, { + driverFactory, + idleTimeout: 100, + minimumPoolSize: 1, + }); + + t.deepEqual( + pool.state(), + { + acquiredConnections: 0, + // TODO we might want to add an option to warm up the pool, in which case this value should be 1 + idleConnections: 0, + pendingDestroyConnections: 0, + pendingReleaseConnections: 0, + state: 'ACTIVE', + waitingClients: 0, + }, + 'initial state', + ); + + await pool.query(sql.unsafe` + SELECT 1 + `); + + t.deepEqual( + pool.state(), + { + acquiredConnections: 0, + idleConnections: 1, + pendingDestroyConnections: 0, + pendingReleaseConnections: 0, + state: 'ACTIVE', + waitingClients: 0, + }, + 'shows idle clients', + ); + + await delay(150); + + t.deepEqual( + pool.state(), + { + acquiredConnections: 0, + idleConnections: 1, + pendingDestroyConnections: 0, + pendingReleaseConnections: 0, + state: 'ACTIVE', + waitingClients: 0, + }, + 'shows idle clients because minimum pool size is 1', + ); + + await pool.end(); + }); + test('retains explicit transaction beyond the idle timeout', async (t) => { const pool = await createPool(t.context.dsn, { driverFactory, diff --git a/packages/slonik/src/types.ts b/packages/slonik/src/types.ts index 679f7abd..282f3bd3 100644 --- a/packages/slonik/src/types.ts +++ b/packages/slonik/src/types.ts @@ -115,9 +115,13 @@ export type ClientConfiguration = { */ readonly interceptors: readonly Interceptor[]; /** - * Do not allow more than this many connections. Use 'DISABLE_TIMEOUT' constant to disable the timeout. (Default: 10) + * Do not allow more than this many connections. (Default: 10) */ - readonly maximumPoolSize: number; + readonly maximumPoolSize?: number; + /** + * Ensure that at least this many connections are available in the pool. (Default: 0) + */ + readonly minimumPoolSize?: number; /** * Number of times a query failing with Transaction Rollback class error, that doesn't belong to a transaction, is retried. (Default: 5) */