├── .nvmrc ├── .npmrc ├── jsdoc.json ├── test ├── mssql-setup │ ├── party-animals-1.csv │ ├── party-animals-2.csv │ ├── entrypoint.sh │ ├── Dockerfile │ ├── import-data.sh │ └── setup.sql ├── serial │ ├── index.test.js │ ├── failover.test.js │ └── priority-promotion.test.js ├── stop-mssql.sh ├── delay.js ├── start-mssql.sh ├── README.md └── parallel │ ├── callback-other.test.js │ ├── warmup-race.test.js │ ├── healing-race.test.js │ ├── promise-execute-many-writes.test.js │ ├── promise-execute-TVP-writes.test.js │ ├── callback-execute.test.js │ ├── promise-execute.test.js │ ├── multiple-dsns.test.js │ ├── callback-query.test.js │ ├── promise-query.test.js │ ├── stats.test.js │ ├── stream-execute.test.js │ ├── stream-query.test.js │ └── prioritized-pools.test.js ├── .gitignore ├── src ├── add-connection-pool-properties.js ├── is-streaming-enabled.js ├── add-default-stats.js ├── pool-error.js ├── copy-pool-stats.js ├── wrap-listeners.js ├── pool-priority-sort.js ├── validate-config.test.js ├── request-method-success.js ├── serial-warmup-strategy.js ├── add-default-dsn-properties.js ├── request-method-failure.js ├── validate-config.js ├── index.js ├── force-fqdn-connection-pool-factory.test.js ├── default-connection-pool-factory.js ├── request-stream-promise.js ├── pool-stats.js ├── force-fqdn-connection-pool-factory.js ├── race-warmup-strategy.js └── connection-pool-party.js ├── .github └── workflows │ └── pr.yaml ├── LICENSE ├── package.json ├── README.md ├── API.md └── CHANGELOG.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["node_modules/jsdoc-babel"] 3 | } -------------------------------------------------------------------------------- /test/mssql-setup/party-animals-1.csv: -------------------------------------------------------------------------------- 1 | 1,Plato 2 | 2,Socrates 3 | 3,Anaximander 4 | 4,Anaximenes 5 | 5,Speusippus 6 | 6,Diogenes 7 | 7,Lycophron 8 | -------------------------------------------------------------------------------- /test/mssql-setup/party-animals-2.csv: -------------------------------------------------------------------------------- 1 | 1,Plato2 2 | 2,Socrates2 3 | 3,Anaximander2 4 | 4,Anaximenes2 5 | 5,Speusippus2 6 | 6,Diogenes2 7 | 7,Lycophron2 8 | -------------------------------------------------------------------------------- /test/serial/index.test.js: -------------------------------------------------------------------------------- 1 | // require tests that must be run serially in this file 2 | 3 | require('./failover.test'); 4 | require('./priority-promotion.test'); 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | pids 4 | *.pid 5 | *.seed 6 | build/Release 7 | node_modules 8 | .idea/* 9 | dist 10 | sandbox.js 11 | coverage/ 12 | *.sw[pon] 13 | -------------------------------------------------------------------------------- /src/add-connection-pool-properties.js: -------------------------------------------------------------------------------- 1 | export default function addConnectionPoolProperties(config) { 2 | return (dsns) => dsns.map((dsn) => ({ 3 | ...dsn, 4 | ...config, 5 | })); 6 | } 7 | -------------------------------------------------------------------------------- /src/is-streaming-enabled.js: -------------------------------------------------------------------------------- 1 | export default function isStreamingEnabled(pool, request) { 2 | return ( 3 | pool.connection.config.stream && 4 | request.stream !== false 5 | ) || request.stream; 6 | } 7 | -------------------------------------------------------------------------------- /test/stop-mssql.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ ! "$1" = "" ]; then 4 | docker stop mssql-pool-party-test-${1} 5 | else 6 | docker stop mssql-pool-party-test-1 7 | docker stop mssql-pool-party-test-2 8 | fi 9 | -------------------------------------------------------------------------------- /src/add-default-stats.js: -------------------------------------------------------------------------------- 1 | /* eslint no-param-reassign: 0 */ 2 | export default function addDefaultStats(pool) { 3 | // pool is mutated here to add stat properties 4 | pool.healCount = 0; 5 | pool.promotionCount = 0; 6 | pool.retryCount = 0; 7 | return pool; 8 | } 9 | -------------------------------------------------------------------------------- /test/delay.js: -------------------------------------------------------------------------------- 1 | // In the event that we use jest.useFakeTimers, it is useful to optionally 2 | // accept a specific implementation of setTimeout. 3 | export default function delay(ms, setTimeoutToUse = setTimeout) { 4 | return () => new Promise((resolve) => { setTimeoutToUse(resolve, ms); }); 5 | } 6 | -------------------------------------------------------------------------------- /src/pool-error.js: -------------------------------------------------------------------------------- 1 | export default class PoolError extends Error { 2 | constructor(pool, err) { 3 | super(err); 4 | // clone the dsn and don't expose the password 5 | if (pool && pool.dsn) { 6 | this.dsn = { ...pool.dsn }; 7 | delete this.dsn.password; 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/mssql-setup/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Starting database with priority ${1}" 4 | 5 | /opt/mssql/bin/sqlservr & 6 | /usr/src/app/import-data.sh $@ 7 | 8 | echo "Import complete." 9 | 10 | # need a better way to keep the container running after bg'ing sqlserver.sh 11 | exec /usr/bin/tail -f /dev/null 12 | -------------------------------------------------------------------------------- /src/copy-pool-stats.js: -------------------------------------------------------------------------------- 1 | /* eslint no-param-reassign: 0 */ 2 | 3 | const statKeys = ['healCount', 'promotionCount', 'retryCount', 'lastPromotionAt', 'lastHealAt']; 4 | 5 | // this function mutates toPool 6 | export default function copyPoolStats(fromPool, toPool) { 7 | statKeys.forEach((statKey) => { 8 | toPool[statKey] = fromPool[statKey]; 9 | }); 10 | return toPool; 11 | } 12 | -------------------------------------------------------------------------------- /src/wrap-listeners.js: -------------------------------------------------------------------------------- 1 | export default function wrapListeners(request, method) { 2 | const originalMethod = request[method]; 3 | return (event, handler) => { 4 | if (event.startsWith('_')) { 5 | originalMethod.call(request, event.substring(1), handler); 6 | } else { 7 | originalMethod.call(request, `poolparty_${event}`, handler); 8 | } 9 | return request; // chaining support 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/pool-priority-sort.js: -------------------------------------------------------------------------------- 1 | export default function poolPrioritySort(a, b) { 2 | if (a.connection.connected && !b.connection.connected) { 3 | return -1; 4 | } 5 | if (!a.connection.connected && b.connection.connected) { 6 | return 1; 7 | } 8 | if (!a.connection.connected && !b.connection.connected) { 9 | return 0; 10 | } 11 | // if both pools are connected, then we sort by priority 12 | return a.dsn.priority - b.dsn.priority; 13 | } 14 | -------------------------------------------------------------------------------- /test/mssql-setup/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/mssql/server:2022-latest 2 | 3 | USER root 4 | 5 | # Create app directory 6 | RUN mkdir -p /usr/src/app 7 | WORKDIR /usr/src/app 8 | 9 | # Bundle app source 10 | COPY . /usr/src/app 11 | 12 | # Grant permissions for the scripts to be executable 13 | RUN chmod +x /usr/src/app/entrypoint.sh 14 | RUN chmod +x /usr/src/app/import-data.sh 15 | 16 | RUN echo 'export "PATH=$PATH:/opt/mssql-tools18/bin"' >> /root/.bashrc 17 | 18 | ENTRYPOINT [ "/usr/src/app/entrypoint.sh" ] 19 | -------------------------------------------------------------------------------- /src/validate-config.test.js: -------------------------------------------------------------------------------- 1 | import validateConfig from './validate-config'; 2 | 3 | describe('validate-config', () => { 4 | it('throws if config does not exist', () => { 5 | expect(() => validateConfig()).toThrow(); 6 | }); 7 | it('throws if config does not contain any dsn properties', () => { 8 | expect(() => validateConfig({})).toThrow(); 9 | }); 10 | it('throws if config contains more than one dsn property', () => { 11 | expect(() => validateConfig({ dsn: {}, dsns: [] })).toThrow(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/request-method-success.js: -------------------------------------------------------------------------------- 1 | import setDebug from 'debug'; 2 | 3 | const debug = setDebug('mssql-pool-party'); 4 | 5 | export default function requestMethodSuccess(request, attempts, cb) { 6 | return () => { 7 | debug('request %s succeeded', request.id); 8 | if (typeof cb === 'function') { 9 | return cb(null, attempts.success); 10 | } 11 | if (request.stream) { 12 | return request.emit('poolparty_done', attempts.success, attempts.attemptNumber); 13 | } 14 | return attempts.success; 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/serial-warmup-strategy.js: -------------------------------------------------------------------------------- 1 | // The serialWarmupStrategy attempts to create connection pools in series, based 2 | // on the order of the dsns provided. 3 | // This isn't a very good strategy to use in practice, but it can be useful for testing 4 | export default function serialWarmupStrategy(dsns, connectionPoolFactory, onCreation, onError) { 5 | return dsns.reduce((p, dsn) => p.then( 6 | () => connectionPoolFactory(dsn).then( 7 | (pool) => onCreation(pool), 8 | (err) => onError(err), 9 | ), 10 | ), Promise.resolve()); 11 | } 12 | -------------------------------------------------------------------------------- /src/add-default-dsn-properties.js: -------------------------------------------------------------------------------- 1 | /* eslint no-param-reassign: 0 */ 2 | import setDebug from 'debug'; 3 | import uuid from 'uuid'; 4 | 5 | const debug = setDebug('mssql-pool-party'); 6 | 7 | export default function addDefaultDsnProperties(dsns) { 8 | debug('adding default dsn properties to the following dsns:\n%O', dsns); 9 | // mutates to add id and createdAt properties to dsns if they don't have one. 10 | return Promise.resolve(dsns.map((dsn) => { 11 | dsn.id = dsn.id || uuid(); 12 | dsn.createdAt = dsn.createdAt || Date.now(); 13 | return dsn; 14 | })); 15 | } 16 | -------------------------------------------------------------------------------- /src/request-method-failure.js: -------------------------------------------------------------------------------- 1 | import setDebug from 'debug'; 2 | 3 | const debug = setDebug('mssql-pool-party'); 4 | 5 | export default function requestMethodFailure(request, attempts, cb) { 6 | return (err) => { 7 | debug(`request ${request.id} failed!`); 8 | debug(err); 9 | if (typeof cb === 'function') { 10 | return cb(err); 11 | } 12 | if (request.stream) { 13 | request.emit('poolparty_error', err, attempts.attemptNumber); 14 | return request.emit('poolparty_done', undefined, attempts.attemptNumber); 15 | } 16 | throw err; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/validate-config.js: -------------------------------------------------------------------------------- 1 | const dsnSources = ['dsn', 'dsns', 'dsnProvider']; 2 | 3 | export default function validateConfig(config) { 4 | if (!config) { 5 | throw new Error('config is a required parameter when instantiating ConnectionPoolParty'); 6 | } 7 | const dsnSourcesInConfig = Object.keys(config).filter((key) => dsnSources.includes(key)); 8 | if (dsnSourcesInConfig.length === 0) { 9 | throw new Error(`One of the following config items is required: ${dsnSources.join(', ')}`); 10 | } 11 | if (dsnSourcesInConfig.length > 1) { 12 | throw new Error(`You can only specify one of the following config items: ${dsnSources.join(', ')}`); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module connection-pool-party 3 | */ 4 | 5 | import debug from 'debug'; 6 | import ConnectionPoolParty from './connection-pool-party'; 7 | import defaultConnectionPoolFactory from './default-connection-pool-factory'; 8 | import forceFqdnConnectionPoolFactory from './force-fqdn-connection-pool-factory'; 9 | 10 | // Send all debug() logs to stdout instead of stderr 11 | /* eslint no-console:0 */ 12 | debug.log = console.log.bind(console); 13 | 14 | // export everything from mssql since we aren't overwriting the existing interface 15 | export * from 'mssql'; 16 | 17 | export { 18 | ConnectionPoolParty, 19 | defaultConnectionPoolFactory, 20 | forceFqdnConnectionPoolFactory, 21 | }; 22 | -------------------------------------------------------------------------------- /test/mssql-setup/import-data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #wait for the SQL Server to come up 4 | # may need to increase this if you have a slower system 5 | sleep 20s 6 | 7 | #run the setup script to create the DB and the schema in the DB 8 | echo Running setup command 9 | /opt/mssql-tools18/bin/sqlcmd \ 10 | -S localhost \ 11 | -U sa \ 12 | -P PoolPartyyy9000 \ 13 | -d master \ 14 | -C \ 15 | -N o \ 16 | -i setup.sql 17 | 18 | #import the data from the csv file 19 | echo Importing data 20 | /opt/mssql-tools18/bin/bcp \ 21 | PoolParty.dbo.PartyAnimals in "/usr/src/app/party-animals-${1}.csv" \ 22 | -c \ 23 | -t ',' \ 24 | -S localhost \ 25 | -U sa \ 26 | -P PoolPartyyy9000 \ 27 | -u \ 28 | -Yo 29 | -------------------------------------------------------------------------------- /src/force-fqdn-connection-pool-factory.test.js: -------------------------------------------------------------------------------- 1 | import defaultConnectionPoolFactory from './default-connection-pool-factory'; 2 | import forceFqdnConnectionPoolFactory from './force-fqdn-connection-pool-factory'; 3 | 4 | jest.mock('./default-connection-pool-factory'); 5 | defaultConnectionPoolFactory.mockImplementation((dsn) => dsn); 6 | 7 | describe('force-fqdn-connection-pool-factory', () => { 8 | it('appends an fqdn suffix to a server that contains an instance', () => { 9 | const factory = forceFqdnConnectionPoolFactory('.some.fqdn'); 10 | const dsn = { 11 | server: 'SOMEHOSTNAME\\SOMEINSTANCE', 12 | }; 13 | const result = factory(dsn); 14 | expect(result.server).toEqual('SOMEHOSTNAME.some.fqdn\\SOMEINSTANCE'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/default-connection-pool-factory.js: -------------------------------------------------------------------------------- 1 | import sql from 'mssql'; 2 | 3 | export default function defaultConnectionPoolFactory(dsn) { 4 | const connection = new sql.ConnectionPool(dsn); 5 | // we don't want an 'Uncaught, unspecified "error" event.' exception 6 | // so we have a dummy listener here. 7 | connection.on('error', () => {}); 8 | return connection.connect() 9 | .then( 10 | // a pool is an object that has dsn and connection properties 11 | () => ({ 12 | connection, 13 | dsn, 14 | }), 15 | // even if we fail to connect, we still want to create the pool, so we 16 | // can attempt to heal it later on 17 | (err) => ({ 18 | connection, 19 | dsn, 20 | error: err, 21 | }), 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: Verify Pull Request 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | name: Run tests on node ${{ matrix.version }} 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | version: [18, 20, 22] 18 | 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 22 | 23 | - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 24 | with: 25 | node-version: ${{ matrix.version }} 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Run tests 31 | run: npm run test:all 32 | 33 | -------------------------------------------------------------------------------- /test/start-mssql.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ ! "$1" = "skip-build" ]; then 4 | docker build -t mssql-pool-party-test ./test/mssql-setup 5 | fi 6 | 7 | if [ ! "$(docker inspect mssql-pool-party-test-1 -f '{{.State.Status}}' 2> /dev/null)" = 'running' ]; then 8 | docker run \ 9 | -e ACCEPT_EULA=Y \ 10 | -e MSSQL_SA_PASSWORD=PoolPartyyy9000 \ 11 | -p 1433:1433 \ 12 | --rm \ 13 | --name mssql-pool-party-test-1 \ 14 | -d mssql-pool-party-test 1 15 | fi 16 | 17 | if [ ! "$(docker inspect mssql-pool-party-test-2 -f '{{.State.Status}}' 2> /dev/null)" = 'running' ]; then 18 | docker run \ 19 | -e ACCEPT_EULA=Y \ 20 | -e MSSQL_SA_PASSWORD=PoolPartyyy9000 \ 21 | -p 1434:1433 \ 22 | --rm \ 23 | --name mssql-pool-party-test-2 \ 24 | -d mssql-pool-party-test 2 25 | fi 26 | 27 | until docker logs mssql-pool-party-test-1 | grep -m 1 'Import complete.'; do sleep 1; done 28 | until docker logs mssql-pool-party-test-2 | grep -m 1 'Import complete.'; do sleep 1; done 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 GoDaddy Operating Company, LLC. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | The tests in this directory require specially configured mssql instances accessible on `localhost:1433` and `localhost:1434`. A helpful Dockerfile has been included in the project that will accomplish this, along with a script to make sure the image is built and the containers are running before the tests start. If you don't have Docker for Windows/Mac installed, you won't be able to run these tests. 4 | 5 | ## Running integration tests 6 | 7 | ```sh 8 | npm run test:docker 9 | npm run test:integration 10 | npm run test:docker-stop 11 | ``` 12 | 13 | Or, to run all tests: 14 | 15 | ```sh 16 | npm run test:all 17 | ``` 18 | 19 | ## Gotchas 20 | 21 | - Make sure you meet the [requirements](https://hub.docker.com/r/microsoft/mssql-server-linux/) mentioned for the mssql-server-linux Docker image. 22 | 23 | - The coverage reports seem to be a little broken. `jest` can't combine coverage across multiple runs natively (and we need multiple runs due to having a few tests that can't be run in parallel). We use `nyc` to combine and report on the json coverage files generated by jest, but the numbers seem off. And html/lcov reports didn't seem to work. 24 | -------------------------------------------------------------------------------- /src/request-stream-promise.js: -------------------------------------------------------------------------------- 1 | import AggregateError from 'aggregate-error'; 2 | 3 | export default function requestStreamPromise(request, originalMethod, attempts) { 4 | return (...args) => new Promise((resolve, reject) => { 5 | const errors = []; 6 | const recordsetHandler = (recordset) => { 7 | request.emit('poolparty_recordset', recordset, attempts.attemptNumber); 8 | }; 9 | const rowHandler = (row) => { 10 | request.emit('poolparty_row', row, attempts.attemptNumber); 11 | }; 12 | const errorHandler = (err) => { 13 | errors.push(err); 14 | request.emit('poolparty_error', err, attempts.attemptNumber); 15 | }; 16 | const doneHandler = (result) => { 17 | request.removeListener('_recordset', recordsetHandler); 18 | request.removeListener('_row', rowHandler); 19 | request.removeListener('_error', errorHandler); 20 | request.removeListener('_done', doneHandler); 21 | if (errors.length > 0) { 22 | return reject(new AggregateError(errors)); 23 | } 24 | return resolve(result); 25 | }; 26 | originalMethod.apply(request, args); 27 | request.on('_recordset', recordsetHandler); 28 | request.on('_row', rowHandler); 29 | request.on('_error', errorHandler); 30 | request.on('_done', doneHandler); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /test/parallel/callback-other.test.js: -------------------------------------------------------------------------------- 1 | import * as sql from '../../src'; 2 | 3 | const config = { 4 | dsn: { 5 | user: 'sa', 6 | password: 'PoolPartyyy9000', 7 | server: 'localhost', 8 | database: 'PoolParty', 9 | trustServerCertificate: true, 10 | encrypt: false, 11 | }, 12 | retries: 1, 13 | reconnects: 1, 14 | }; 15 | 16 | let connection; 17 | 18 | describe('other tests using callback interface', () => { 19 | afterEach(() => connection.close()); 20 | it(`constructor accepts an optional callback which will trigger a warmup and 21 | call it afterwrard`, (done) => { 22 | connection = new sql.ConnectionPoolParty(config, () => { 23 | expect(connection.pools[0].connection.connected).toBe(true); 24 | done(); 25 | }); 26 | }); 27 | it('warmup accepts an optional callback', (done) => { 28 | connection = new sql.ConnectionPoolParty(config); 29 | connection.warmup(() => { 30 | expect(connection.pools[0].connection.connected).toBe(true); 31 | done(); 32 | }); 33 | }); 34 | it('close accepts an optional callback', (done) => { 35 | connection = new sql.ConnectionPoolParty(config); 36 | connection.warmup().then(() => { 37 | expect(connection.pools[0].connection.connected).toBe(true); 38 | connection.close(() => { 39 | expect(connection.pools.length).toBe(0); 40 | done(); 41 | }); 42 | }); 43 | }); 44 | // Other methods have callback interface tests in their own files (execute, query, etc) 45 | }); 46 | -------------------------------------------------------------------------------- /test/parallel/warmup-race.test.js: -------------------------------------------------------------------------------- 1 | import defaultConnectionPoolFactory from '../../src/default-connection-pool-factory'; 2 | import * as sql from '../../src'; 3 | 4 | let connection; 5 | const factorySpy = jest.fn(defaultConnectionPoolFactory); 6 | 7 | describe('warmup race tests', () => { 8 | beforeEach(() => { 9 | factorySpy.mockClear(); 10 | connection = new sql.ConnectionPoolParty({ 11 | // the dsns are the same, but are sufficient for these tests 12 | dsn: { 13 | user: 'sa', 14 | password: 'PoolPartyyy9000', 15 | server: 'localhost', 16 | database: 'PoolParty', 17 | trustServerCertificate: true, 18 | encrypt: false, 19 | }, 20 | reconnects: 1, 21 | connectionPoolFactory: factorySpy, 22 | }); 23 | }); 24 | afterEach(() => connection.close()); 25 | it('multiple simultaneous requests with implicit warmup only result in a single warmup', () => Promise.all([ 26 | connection.request().query('select * from PartyAnimals'), 27 | connection.request().query('select * from PartyAnimals'), 28 | connection.request().query('select * from PartyAnimals'), 29 | connection.request().query('select * from PartyAnimals'), 30 | connection.request().query('select * from PartyAnimals'), 31 | connection.request().query('select * from PartyAnimals'), 32 | ]) 33 | .then(() => { 34 | // confirm primary is reconnected 35 | expect(connection.pools[0].connection.connected).toEqual(true); 36 | expect(factorySpy).toHaveBeenCalledTimes(1); 37 | })); 38 | }); 39 | -------------------------------------------------------------------------------- /src/pool-stats.js: -------------------------------------------------------------------------------- 1 | export default function poolStats(pool) { 2 | const { 3 | lastPromotionAt, 4 | lastHealAt, 5 | promotionCount, 6 | healCount, 7 | retryCount, 8 | } = pool; 9 | const { 10 | priority, 11 | } = pool.dsn; 12 | const { 13 | connecting, 14 | connected, 15 | healthy, 16 | } = pool.connection; 17 | const { 18 | user, 19 | server, 20 | database, 21 | id, 22 | createdAt, 23 | port, 24 | connectTimeout, 25 | requestTimeout, 26 | } = pool.connection.config; 27 | const { 28 | readOnlyIntent, 29 | appName, 30 | encrypt, 31 | } = pool.connection.config.options; 32 | const { 33 | max, 34 | min, 35 | acquireTimeoutMillis, 36 | createTimeoutMillis, 37 | idleTimeoutMillis, 38 | } = (pool.connection.pool || {}); 39 | return { 40 | health: { 41 | connected, 42 | connecting, 43 | healthy, 44 | lastHealAt, 45 | lastPromotionAt, 46 | healCount, 47 | promotionCount, 48 | retryCount, 49 | }, 50 | config: { 51 | user, 52 | server, 53 | database, 54 | id, 55 | priority, 56 | createdAt, 57 | port, 58 | appName, 59 | encrypt, 60 | readOnlyIntent, 61 | poolMin: min, 62 | poolMax: max, 63 | }, 64 | timeouts: { 65 | connect: connectTimeout, 66 | request: requestTimeout, 67 | poolAcquire: acquireTimeoutMillis, 68 | poolCreate: createTimeoutMillis, 69 | poolIdle: idleTimeoutMillis, 70 | }, 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /test/parallel/healing-race.test.js: -------------------------------------------------------------------------------- 1 | import * as sql from '../../src'; 2 | 3 | let connection; 4 | 5 | describe('healing race tests', () => { 6 | beforeEach(() => { 7 | connection = new sql.ConnectionPoolParty({ 8 | // the dsns are the same, but are sufficient for these tests 9 | dsn: { 10 | user: 'sa', 11 | password: 'PoolPartyyy9000', 12 | server: 'localhost', 13 | database: 'PoolParty', 14 | trustServerCertificate: true, 15 | encrypt: false, 16 | }, 17 | reconnects: 1, 18 | }); 19 | }); 20 | afterEach(() => connection.close()); 21 | it('multiple simultaneous requests only result in a single healing attempt on unhealthy pool', 22 | () => connection.warmup() 23 | .then(() => connection.pools[0].connection.close()) 24 | .then(() => { 25 | // confirm primary is closed 26 | expect(connection.pools[0].connection.connected).toEqual(false); 27 | return Promise.all([ 28 | connection.request().query('select * from PartyAnimals'), 29 | connection.request().query('select * from PartyAnimals'), 30 | connection.request().query('select * from PartyAnimals'), 31 | connection.request().query('select * from PartyAnimals'), 32 | connection.request().query('select * from PartyAnimals'), 33 | connection.request().query('select * from PartyAnimals'), 34 | ]); 35 | }) 36 | .then(() => { 37 | // confirm primary is reconnected 38 | expect(connection.pools[0].connection.connected).toEqual(true); 39 | expect(connection.stats().pools[0].health.healCount).toEqual(1); 40 | })); 41 | }); 42 | -------------------------------------------------------------------------------- /src/force-fqdn-connection-pool-factory.js: -------------------------------------------------------------------------------- 1 | import defaultConnectionPoolFactory from './default-connection-pool-factory'; 2 | 3 | function coerceFqdn(server, suffix) { 4 | if (!server) { 5 | return server; 6 | } 7 | // If we detect any periods, we assume this is the fqdn 8 | if (server.indexOf('.') >= 0) { 9 | return server; 10 | } 11 | 12 | // server has an instance, we can't just append the fqdn 13 | if (server.indexOf('\\') >= 0) { 14 | const parts = server.split('\\'); 15 | if (parts.length !== 2) { 16 | // we only know how to parse SERVER\INSTANCE format 17 | return server; 18 | } 19 | return `${parts[0] + suffix}\\${parts[1]}`; 20 | } 21 | 22 | return server + suffix; 23 | } 24 | 25 | /** 26 | * This connection pool factory is only needed for a niche use case, but it 27 | * serves as an example of what is possible when creating a custom 28 | * connectionPoolFactory. 29 | * 30 | * If your dsn provider returns servers as hostnames instead of FQDNs or IPs, 31 | * you may have systems that are unable to resolve the hostnames due to 32 | * misconfigured DNS settings. If you are unable to fix the DNS resolution for 33 | * whatever reason, and you know what the FQDN suffix is, you can use this 34 | * connectionPoolFactory to add the suffix. 35 | * @param {string} suffix - The FQDN suffix to use if your dsn's server is provided 36 | * as a hostname. 37 | * @return {Promise} A promise that uses a dsn provided by the dsnProvider to create 38 | * an mssql ConnectionPool. 39 | * @memberof module:connection-pool-party 40 | */ 41 | export default function forceFqdnConnectionPoolFactory(suffix) { 42 | return (dsn) => { 43 | dsn.server = coerceFqdn(dsn.server, suffix); // eslint-disable-line no-param-reassign 44 | return defaultConnectionPoolFactory(dsn); 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /test/mssql-setup/setup.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE PoolParty; 2 | GO 3 | USE PoolParty; 4 | GO 5 | CREATE TABLE PartyAnimals ( 6 | ID int primary key IDENTITY(1,1) NOT NULL, 7 | PartyAnimalName nvarchar(max) 8 | ); 9 | GO 10 | CREATE PROCEDURE GetPartyAnimalByID 11 | @ID int 12 | AS 13 | SET NOCOUNT ON; 14 | SELECT * 15 | FROM PartyAnimals 16 | WHERE ID = @ID 17 | GO 18 | CREATE TYPE PoolToysTableType AS TABLE ( 19 | PoolToyName nvarchar(max) 20 | ); 21 | GO 22 | 23 | CREATE TABLE PoolToys ( 24 | ID int primary key IDENTITY(1,1) NOT NULL, 25 | PoolToyName nvarchar(max) 26 | ); 27 | GO 28 | CREATE PROCEDURE GetPoolToys 29 | AS 30 | SET NOCOUNT ON; 31 | SELECT * 32 | FROM PoolToys 33 | GO 34 | CREATE PROCEDURE AddPoolToy 35 | @PoolToyName nvarchar(max) 36 | AS 37 | BEGIN 38 | SET NOCOUNT ON; 39 | INSERT INTO PoolToys (PoolToyName) 40 | VALUES (@PoolToyName) 41 | END 42 | GO 43 | CREATE PROCEDURE AddPoolToyTVP ( 44 | @poolToys PoolToysTableType READONLY 45 | ) 46 | AS 47 | BEGIN 48 | SET NOCOUNT ON; 49 | INSERT INTO PoolToys (PoolToyName) 50 | SELECT pt.PoolToyName FROM @poolToys AS pt; 51 | END 52 | GO 53 | 54 | /* duplicate objects for parallel integration tests */ 55 | CREATE TABLE PoolToys2 ( 56 | ID int primary key IDENTITY(1,1) NOT NULL, 57 | PoolToyName nvarchar(max) 58 | ); 59 | GO 60 | CREATE PROCEDURE GetPoolToys2 61 | AS 62 | SET NOCOUNT ON; 63 | SELECT * 64 | FROM PoolToys2 65 | GO 66 | CREATE PROCEDURE AddPoolToy2 67 | @PoolToyName nvarchar(max) 68 | AS 69 | BEGIN 70 | SET NOCOUNT ON; 71 | INSERT INTO PoolToys2 (PoolToyName) 72 | VALUES (@PoolToyName) 73 | END 74 | GO 75 | CREATE PROCEDURE AddPoolToyTVP2 ( 76 | @poolToys PoolToysTableType READONLY 77 | ) 78 | AS 79 | BEGIN 80 | SET NOCOUNT ON; 81 | INSERT INTO PoolToys2 (PoolToyName) 82 | SELECT pt.PoolToyName FROM @poolToys AS pt; 83 | END 84 | GO 85 | -------------------------------------------------------------------------------- /test/parallel/promise-execute-many-writes.test.js: -------------------------------------------------------------------------------- 1 | import * as sql from '../../src'; 2 | import delay from '../delay'; 3 | 4 | let connection; 5 | 6 | jest.setTimeout(60_000); 7 | 8 | describe('execute many writes tests using promise interface', () => { 9 | beforeEach(() => { 10 | connection = new sql.ConnectionPoolParty({ 11 | dsn: { 12 | user: 'sa', 13 | password: 'PoolPartyyy9000', 14 | server: 'localhost', 15 | database: 'PoolParty', 16 | trustServerCertificate: true, 17 | encrypt: false, 18 | }, 19 | retries: 1, 20 | reconnects: 1, 21 | connectionPoolOptions: { 22 | options: { 23 | trustServerCertificate: true, 24 | encrypt: false, 25 | }, 26 | }, 27 | }); 28 | }); 29 | 30 | afterEach(() => connection.request() 31 | .query('TRUNCATE TABLE PoolParty.dbo.PoolToys;') 32 | .then(() => connection.close())); 33 | 34 | it('perform 10000 writes', 35 | () => connection.warmup() 36 | .then(() => connection.request().query('SELECT * FROM PoolParty.dbo.PoolToys')) 37 | .then((results) => { 38 | expect(results.recordset.length).toEqual(0); 39 | }) 40 | .then(() => { 41 | const randomValues = [...new Array(10000)].map( 42 | () => Math.random().toString(36).substring(7), 43 | ); 44 | return Promise.all( 45 | randomValues.map( 46 | (name) => connection.request() 47 | .input('PoolToyName', sql.NVarChar, name) 48 | .execute('AddPoolToy'), 49 | ), 50 | ); 51 | }) 52 | .then((results) => { 53 | expect(results.every((result) => result.returnValue === 0)).toEqual(true); 54 | }) 55 | .then(delay(5000)) // allow all writes to be flushed from the buffer. 56 | .then(() => connection.request().query('SELECT * FROM PoolParty.dbo.PoolToys')) 57 | .then((results) => { 58 | expect(results.recordset.length).toEqual(10000); 59 | })); 60 | }); 61 | -------------------------------------------------------------------------------- /test/parallel/promise-execute-TVP-writes.test.js: -------------------------------------------------------------------------------- 1 | import * as sql from '../../src'; 2 | import delay from '../delay'; 3 | 4 | jest.setTimeout(60_000); 5 | 6 | let connection; 7 | 8 | describe('execute TVP write using promise interface', () => { 9 | beforeEach(() => { 10 | connection = new sql.ConnectionPoolParty({ 11 | dsn: { 12 | user: 'sa', 13 | password: 'PoolPartyyy9000', 14 | server: 'localhost', 15 | database: 'PoolParty', 16 | trustServerCertificate: true, 17 | encrypt: false, 18 | }, 19 | connectionPoolOptions: { 20 | options: { 21 | trustServerCertificate: true, 22 | encrypt: false, 23 | }, 24 | }, 25 | }); 26 | }); 27 | afterEach(() => connection.request() 28 | .query('TRUNCATE TABLE PoolParty.dbo.PoolToys2;') 29 | .then(() => connection.close())); 30 | it('execute proc with TVP containing 10000 rows', 31 | () => connection.warmup() 32 | .then(() => connection.request().query('SELECT * FROM PoolParty.dbo.PoolToys2')) 33 | .then((results) => { 34 | expect(results.recordset.length).toEqual(0); 35 | }) 36 | .then(() => { 37 | const randomValues = [...new Array(10000)].map( 38 | () => Math.random().toString(36).substring(7), 39 | ); 40 | const tvp = new sql.Table(); 41 | tvp.columns.add('PoolToyName', sql.NVarChar); 42 | randomValues.forEach((value) => { 43 | tvp.rows.add(value); 44 | }); 45 | return connection.request() 46 | .input('poolToys', sql.TVP, tvp) 47 | .execute('AddPoolToyTVP2'); 48 | }) 49 | .then((results) => { 50 | expect(results.returnValue).toEqual(0); 51 | }) 52 | .then(delay(5000)) // allow all writes to be flushed from the buffer. 53 | .then(() => connection.request().query('SELECT * FROM PoolParty.dbo.PoolToys2')) 54 | .then((results) => { 55 | expect(results.recordset.length).toEqual(10000); 56 | })); 57 | }); 58 | -------------------------------------------------------------------------------- /test/serial/failover.test.js: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import * as sql from '../../src'; 3 | import serialWarmupStrategy from '../../src/serial-warmup-strategy'; 4 | 5 | let connection; 6 | 7 | describe('failover tests', () => { 8 | jest.setTimeout(60000); 9 | beforeEach(async () => { 10 | await execa('sh', ['test/start-mssql.sh', 'skip-build']); 11 | connection = new sql.ConnectionPoolParty({ 12 | dsns: [{ 13 | user: 'sa', 14 | password: 'PoolPartyyy9000', 15 | server: 'localhost', 16 | database: 'PoolParty', 17 | trustServerCertificate: true, 18 | encrypt: false, 19 | }, { 20 | user: 'sa', 21 | password: 'PoolPartyyy9000', 22 | server: 'localhost', 23 | database: 'PoolParty', 24 | port: 1434, 25 | trustServerCertificate: true, 26 | encrypt: false, 27 | }], 28 | retries: 1, 29 | reconnects: 1, 30 | warmupStrategy: serialWarmupStrategy, 31 | }); 32 | }); 33 | afterEach(() => { 34 | connection.close(); 35 | }); 36 | it('fails over to second DSN if first fails', async () => { 37 | await connection.warmup(); 38 | const result = await connection.request().query('select * from PartyAnimals'); 39 | expect(result.recordset[0].PartyAnimalName).toBe('Plato'); 40 | await execa('sh', ['test/stop-mssql.sh', '1']); 41 | const result2 = await connection.request().query('select * from PartyAnimals'); 42 | expect(result2.recordset[0].PartyAnimalName).toBe('Plato2'); 43 | }); 44 | it(`fails over to second DSN if first fails AND the second DSN 45 | failed during initial warmup but is now available`, async () => { 46 | await execa('sh', ['test/stop-mssql.sh', '2']); 47 | await connection.warmup(); 48 | const result = await connection.request().query('select * from PartyAnimals'); 49 | expect(result.recordset[0].PartyAnimalName).toBe('Plato'); 50 | await execa('sh', ['test/start-mssql.sh']); 51 | await execa('sh', ['test/stop-mssql.sh', '1']); 52 | const result2 = await connection.request().query('select * from PartyAnimals'); 53 | expect(result2.recordset[0].PartyAnimalName).toBe('Plato2'); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/serial/priority-promotion.test.js: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import * as sql from '../../src'; 3 | import serialWarmupStrategy from '../../src/serial-warmup-strategy'; 4 | import delay from '../delay'; 5 | 6 | let connection; 7 | 8 | describe('priority/promotion tests', () => { 9 | jest.setTimeout(60000); 10 | beforeEach(async () => { 11 | await execa('sh', ['test/start-mssql.sh', 'skip-build']); 12 | connection = new sql.ConnectionPoolParty({ 13 | dsns: [{ 14 | id: 'pool1', 15 | user: 'sa', 16 | password: 'PoolPartyyy9000', 17 | server: 'localhost', 18 | database: 'PoolParty', 19 | priority: 0, 20 | trustServerCertificate: true, 21 | encrypt: false, 22 | }, { 23 | id: 'pool2', 24 | user: 'sa', 25 | password: 'PoolPartyyy9000', 26 | server: 'localhost', 27 | database: 'PoolParty', 28 | port: 1434, 29 | priority: 1, 30 | trustServerCertificate: true, 31 | encrypt: false, 32 | }], 33 | prioritizeInterval: 5000, 34 | prioritizePools: true, 35 | retries: 1, 36 | reconnects: 1, 37 | warmupStrategy: serialWarmupStrategy, 38 | }); 39 | }); 40 | afterEach(() => { 41 | connection.close(); 42 | }); 43 | it(`promotes the lower priority DSN to primary if high priority pool 44 | fails and then repromotes the higher priority pool once it is healthy again`, async () => { 45 | await connection.warmup(); 46 | const result = await connection.request().query('select * from PartyAnimals'); 47 | expect(result.recordset[0].PartyAnimalName).toBe('Plato'); 48 | expect(connection.stats().pools[0].config.id).toBe('pool1'); 49 | await execa('sh', ['test/stop-mssql.sh', '1']); 50 | const result2 = await connection.request().query('select * from PartyAnimals'); 51 | expect(result2.recordset[0].PartyAnimalName).toBe('Plato2'); 52 | expect(connection.stats().pools[0].config.id).toBe('pool2'); 53 | await execa('sh', ['test/start-mssql.sh', 'skip-build']); 54 | await delay(6000)(); // need to make sure the prioritize loop has elapsed 55 | const result3 = await connection.request().query('select * from PartyAnimals'); 56 | expect(result3.recordset[0].PartyAnimalName).toBe('Plato'); 57 | expect(connection.stats().pools[0].config.id).toBe('pool1'); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/race-warmup-strategy.js: -------------------------------------------------------------------------------- 1 | import setDebug from 'debug'; 2 | 3 | const debug = setDebug('mssql-pool-party'); 4 | 5 | function rejectIfNoRemainingPools(reject, processed, total, resolved) { 6 | if (processed >= total && !resolved) { 7 | debug('warmup: failed, no pools healthy pools created'); 8 | reject(new Error(` 9 | The connectionPoolFactory failed to create any pool(s) using 10 | the dsn(s) provided. Warmup has failed. 11 | `)); 12 | } 13 | } 14 | 15 | // The raceWarmupStrategy attempts to create connection pools in parallel. 16 | // The first to succeed will resolve the promise, signaling that the warmup is done. 17 | // If there is more than one dsn, pools will be created for the rest in the background 18 | // and added to the ConnectionPoolParty pools collection as they are created. 19 | export default function raceWarmupStrategy(dsns, connectionPoolFactory, onCreation, onError) { 20 | // have we successfully created a pool? 21 | let resolved = false; 22 | // the number of pools we've created/failed 23 | let numberProcessed = 0; 24 | 25 | return new Promise((resolve, reject) => { 26 | // for each dsn, create a pool 27 | dsns.forEach((dsn) => { 28 | connectionPoolFactory(dsn) 29 | .then( 30 | // if we succeed in creating a pool, pass it to onCreation 31 | // and resolve if we haven't already done so 32 | (pool) => { 33 | numberProcessed += 1; 34 | debug('warmup: pool %d created %s%O', numberProcessed, (pool.error && 'WITH ERROR\n') || '', pool.error); 35 | onCreation(pool); 36 | // we only want to resolve once 37 | if (!resolved && !pool.error) { 38 | resolved = true; 39 | debug('warmup: resolved'); 40 | resolve(); 41 | } 42 | rejectIfNoRemainingPools(reject, numberProcessed, dsns.length, resolved); 43 | }, 44 | // normally, connectionPoolFactory should never reject, 45 | // since we want it to create a pool even if it's unhealthy, 46 | // so we can attempt to heal the pool later 47 | (err) => { 48 | numberProcessed += 1; 49 | debug('warmup: connectionPoolFactory errored'); 50 | onError(err); 51 | rejectIfNoRemainingPools(reject, numberProcessed, dsns.length, resolved); 52 | }, 53 | ); 54 | }); 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /test/parallel/callback-execute.test.js: -------------------------------------------------------------------------------- 1 | import AggregateError from 'aggregate-error'; 2 | import * as sql from '../../src'; 3 | 4 | const procResults = { 5 | output: {}, 6 | recordset: [{ ID: 6, PartyAnimalName: 'Diogenes' }], 7 | recordsets: [ 8 | [{ ID: 6, PartyAnimalName: 'Diogenes' }], 9 | ], 10 | returnValue: 0, 11 | rowsAffected: [], 12 | }; 13 | 14 | let connection; 15 | 16 | describe('execute (stored procedures) tests using callback interface', () => { 17 | beforeEach(() => { 18 | connection = new sql.ConnectionPoolParty({ 19 | dsn: { 20 | user: 'sa', 21 | password: 'PoolPartyyy9000', 22 | server: 'localhost', 23 | database: 'PoolParty', 24 | trustServerCertificate: true, 25 | encrypt: false, 26 | }, 27 | retries: 1, 28 | reconnects: 1, 29 | }); 30 | }); 31 | afterEach(() => connection.close()); 32 | it('returns expected results with explicit warmup', (done) => { 33 | connection.warmup() 34 | .then(() => { 35 | expect(connection.pools[0].connection.connected).toEqual(true); 36 | connection.request() 37 | .input('ID', sql.Int, 6) 38 | .execute('GetPartyAnimalByID', (err, result) => { 39 | expect(result).toEqual(procResults); 40 | done(); 41 | }); 42 | }); 43 | }); 44 | it('returns expected results with implicit warmup', (done) => { 45 | expect(connection.pools.length).toEqual(0); 46 | connection.request() 47 | .input('ID', sql.Int, 6) 48 | .execute('GetPartyAnimalByID', (err, result) => { 49 | expect(result).toEqual(procResults); 50 | done(); 51 | }); 52 | }); 53 | it('returns expected results after a reconnect', (done) => { 54 | connection.warmup() 55 | .then(() => { 56 | expect(connection.pools[0].connection.connected).toEqual(true); 57 | // manually closing a connection to simulate failure 58 | return connection.pools[0].connection.close(); 59 | }) 60 | .then(() => { 61 | // verify connection has been manually closed 62 | expect(connection.pools[0].connection.connected).toEqual(false); 63 | connection.request() 64 | .input('ID', sql.Int, 6) 65 | .execute('GetPartyAnimalByID', (err, result) => { 66 | expect(result).toEqual(procResults); 67 | done(); 68 | }); 69 | }); 70 | }); 71 | it('rejects with an error with an unhealthy connection and 0 reconnects', (done) => { 72 | connection.warmup() 73 | .then(() => { 74 | expect(connection.pools[0].connection.connected).toEqual(true); 75 | // manually closing a connection to simulate failure 76 | return connection.pools[0].connection.close(); 77 | }) 78 | .then(() => { 79 | // verify connection has been manually closed 80 | expect(connection.pools[0].connection.connected).toEqual(false); 81 | connection.request({ reconnects: 0 }) 82 | .input('ID', sql.Int, 6) 83 | .execute('GetPartyAnimalByID', (err) => { 84 | expect(err).toBeInstanceOf(AggregateError); 85 | done(); 86 | }); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/parallel/promise-execute.test.js: -------------------------------------------------------------------------------- 1 | import AggregateError from 'aggregate-error'; 2 | import * as sql from '../../src'; 3 | 4 | const procResults = { 5 | output: {}, 6 | recordset: [{ ID: 6, PartyAnimalName: 'Diogenes' }], 7 | recordsets: [ 8 | [{ ID: 6, PartyAnimalName: 'Diogenes' }], 9 | ], 10 | returnValue: 0, 11 | rowsAffected: [], 12 | }; 13 | 14 | let connection; 15 | 16 | describe('execute (stored procedures) tests using promise interface', () => { 17 | beforeEach(() => { 18 | connection = new sql.ConnectionPoolParty({ 19 | dsn: { 20 | user: 'sa', 21 | password: 'PoolPartyyy9000', 22 | server: 'localhost', 23 | database: 'PoolParty', 24 | trustServerCertificate: true, 25 | encrypt: false, 26 | }, 27 | retries: 1, 28 | reconnects: 1, 29 | }); 30 | }); 31 | afterEach(() => connection.close()); 32 | it('returns expected results with explicit warmup', 33 | () => connection.warmup() 34 | .then(() => { 35 | expect(connection.pools[0].connection.connected).toEqual(true); 36 | return connection.request() 37 | .input('ID', sql.Int, 6) 38 | .execute('GetPartyAnimalByID') 39 | .then((result) => { 40 | expect(result).toEqual(procResults); 41 | }); 42 | })); 43 | it('returns expected results with implicit warmup', () => { 44 | expect(connection.pools.length).toEqual(0); 45 | return connection.request() 46 | .input('ID', sql.Int, 6) 47 | .execute('GetPartyAnimalByID') 48 | .then((result) => { 49 | expect(result).toEqual(procResults); 50 | }); 51 | }); 52 | it('returns expected results after a reconnect', 53 | () => connection.warmup() 54 | .then(() => { 55 | expect(connection.pools[0].connection.connected).toEqual(true); 56 | // manually closing a connection to simulate failure 57 | return connection.pools[0].connection.close(); 58 | }) 59 | .then(() => { 60 | // verify connection has been manually closed 61 | expect(connection.pools[0].connection.connected).toEqual(false); 62 | return connection.request() 63 | .input('ID', sql.Int, 6) 64 | .execute('GetPartyAnimalByID') 65 | .then((result) => { 66 | expect(result).toEqual(procResults); 67 | }); 68 | })); 69 | it('rejects with an error with an unhealthy connection and 0 reconnects', 70 | () => connection.warmup() 71 | .then(() => { 72 | expect(connection.pools[0].connection.connected).toEqual(true); 73 | // manually closing a connection to simulate failure 74 | return connection.pools[0].connection.close(); 75 | }) 76 | .then(() => { 77 | // verify connection has been manually closed 78 | expect(connection.pools[0].connection.connected).toEqual(false); 79 | return connection.request({ reconnects: 0 }) 80 | .input('ID', sql.Int, 6) 81 | .execute('GetPartyAnimalByID') 82 | .then( 83 | () => { expect('the promise should reject').toEqual(false); }, 84 | (err) => { 85 | expect(err).toBeInstanceOf(AggregateError); 86 | }, 87 | ); 88 | })); 89 | }); 90 | -------------------------------------------------------------------------------- /test/parallel/multiple-dsns.test.js: -------------------------------------------------------------------------------- 1 | import AggregateError from 'aggregate-error'; 2 | import * as sql from '../../src'; 3 | import delay from '../delay'; 4 | 5 | let connection; 6 | 7 | describe('multiple dsn tests', () => { 8 | beforeEach(() => { 9 | connection = new sql.ConnectionPoolParty({ 10 | // the dsns are the same, but are sufficient for these tests 11 | dsns: [ 12 | { 13 | user: 'sa', 14 | password: 'PoolPartyyy9000', 15 | server: 'localhost', 16 | database: 'PoolParty', 17 | trustServerCertificate: true, 18 | encrypt: false, 19 | }, 20 | { 21 | user: 'sa', 22 | password: 'PoolPartyyy9000', 23 | server: 'localhost', 24 | database: 'PoolParty', 25 | trustServerCertificate: true, 26 | encrypt: false, 27 | }, 28 | ], 29 | }); 30 | }); 31 | afterEach(() => connection.close()); 32 | it(`secondary pool is promoted when the primary is unhealthy and the secondary succeeds. 33 | in addition, the former primary is not healed, it remains unhealthy after demotion.`, () => { 34 | let primaryId; 35 | let secondaryId; 36 | return connection.warmup() 37 | .then(delay(100)) 38 | .then(() => { 39 | // confirm both pools are connected, then close the primary 40 | expect(connection.pools[0].connection.connected).toEqual(true); 41 | expect(connection.pools[1].connection.connected).toEqual(true); 42 | primaryId = connection.pools[0].dsn.id; 43 | secondaryId = connection.pools[1].dsn.id; 44 | return connection.pools[0].connection.close(); 45 | }) 46 | .then(() => { 47 | // confirm primary is closed 48 | expect(connection.pools[0].connection.connected).toEqual(false); 49 | expect(connection.pools[1].connection.connected).toEqual(true); 50 | return connection.request() 51 | .query('select * from PartyAnimals'); 52 | }) 53 | .then((result) => { 54 | expect(result.recordset.length).toEqual(7); 55 | // confirm the secondary is now promoted to primary and the old primary is closed 56 | expect(connection.pools[0].dsn.id).toEqual(secondaryId); 57 | expect(connection.pools[1].dsn.id).toEqual(primaryId); 58 | expect(connection.pools[1].connection.connected).toEqual(false); 59 | }); 60 | }); 61 | it('all pools are healed after a failed request', 62 | () => connection.warmup() 63 | .then(delay(100)) 64 | .then(() => { 65 | // confirm both pools are connected, then close both 66 | expect(connection.pools[0].connection.connected).toEqual(true); 67 | expect(connection.pools[1].connection.connected).toEqual(true); 68 | return Promise.all([ 69 | connection.pools[0].connection.close(), 70 | connection.pools[1].connection.close(), 71 | ]); 72 | }) 73 | .then(() => { 74 | // confirm both are closed 75 | expect(connection.pools[0].connection.connected).toEqual(false); 76 | expect(connection.pools[1].connection.connected).toEqual(false); 77 | return connection.request() 78 | .query('select * from PartyAnimals'); 79 | }) 80 | .then( 81 | () => { expect('this promise should reject').toEqual(false); }, 82 | (err) => { 83 | expect(err).toBeInstanceOf(AggregateError); 84 | // confirm the pools have been healed 85 | expect(connection.pools[0].connection.connected).toEqual(true); 86 | expect(connection.pools[1].connection.connected).toEqual(true); 87 | }, 88 | )); 89 | }); 90 | -------------------------------------------------------------------------------- /test/parallel/callback-query.test.js: -------------------------------------------------------------------------------- 1 | import AggregateError from 'aggregate-error'; 2 | import * as sql from '../../src'; 3 | 4 | const queryResults = { 5 | output: {}, 6 | recordset: [ 7 | { ID: 1, PartyAnimalName: 'Plato' }, 8 | { ID: 2, PartyAnimalName: 'Socrates' }, 9 | { ID: 3, PartyAnimalName: 'Anaximander' }, 10 | { ID: 4, PartyAnimalName: 'Anaximenes' }, 11 | { ID: 5, PartyAnimalName: 'Speusippus' }, 12 | { ID: 6, PartyAnimalName: 'Diogenes' }, 13 | { ID: 7, PartyAnimalName: 'Lycophron' }, 14 | ], 15 | recordsets: [ 16 | [ 17 | { ID: 1, PartyAnimalName: 'Plato' }, 18 | { ID: 2, PartyAnimalName: 'Socrates' }, 19 | { ID: 3, PartyAnimalName: 'Anaximander' }, 20 | { ID: 4, PartyAnimalName: 'Anaximenes' }, 21 | { ID: 5, PartyAnimalName: 'Speusippus' }, 22 | { ID: 6, PartyAnimalName: 'Diogenes' }, 23 | { ID: 7, PartyAnimalName: 'Lycophron' }, 24 | ], 25 | ], 26 | rowsAffected: [7], 27 | }; 28 | 29 | let connection; 30 | 31 | describe('query tests using callback interface', () => { 32 | beforeEach(() => { 33 | connection = new sql.ConnectionPoolParty({ 34 | dsn: { 35 | user: 'sa', 36 | password: 'PoolPartyyy9000', 37 | server: 'localhost', 38 | database: 'PoolParty', 39 | trustServerCertificate: true, 40 | encrypt: false, 41 | }, 42 | retries: 1, 43 | reconnects: 1, 44 | }); 45 | }); 46 | afterEach(() => connection.close()); 47 | it('returns expected results with explicit warmup', (done) => { 48 | connection.warmup() 49 | .then(() => { 50 | expect(connection.pools[0].connection.connected).toEqual(true); 51 | connection.request() 52 | .query('select * from PartyAnimals', (err, result) => { 53 | expect(result).toEqual(queryResults); 54 | done(); 55 | }); 56 | }); 57 | }); 58 | it('returns expected results with implicit warmup', (done) => { 59 | expect(connection.pools.length).toEqual(0); 60 | connection.request() 61 | .query('select * from PartyAnimals', (err, result) => { 62 | expect(result).toEqual(queryResults); 63 | done(); 64 | }); 65 | }); 66 | it('returns expected results after a reconnect', (done) => { 67 | connection.warmup() 68 | .then(() => { 69 | expect(connection.pools[0].connection.connected).toEqual(true); 70 | // manually closing a connection to simulate failure 71 | return connection.pools[0].connection.close(); 72 | }) 73 | .then(() => { 74 | // verify connection has been manually closed 75 | expect(connection.pools[0].connection.connected).toEqual(false); 76 | connection.request() 77 | .query('select * from PartyAnimals', (err, result) => { 78 | expect(result).toEqual(queryResults); 79 | done(); 80 | }); 81 | }); 82 | }); 83 | it('rejects with an error with an unhealthy connection and 0 reconnects', (done) => { 84 | connection.warmup() 85 | .then(() => { 86 | expect(connection.pools[0].connection.connected).toEqual(true); 87 | // manually closing a connection to simulate failure 88 | return connection.pools[0].connection.close(); 89 | }) 90 | .then(() => { 91 | // verify connection has been manually closed 92 | expect(connection.pools[0].connection.connected).toEqual(false); 93 | connection.request({ reconnects: 0 }) 94 | .query('select * from PartyAnimals', (err) => { 95 | expect(err).toBeInstanceOf(AggregateError); 96 | done(); 97 | }); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /test/parallel/promise-query.test.js: -------------------------------------------------------------------------------- 1 | import AggregateError from 'aggregate-error'; 2 | import * as sql from '../../src'; 3 | 4 | const queryResults = { 5 | output: {}, 6 | recordset: [ 7 | { ID: 1, PartyAnimalName: 'Plato' }, 8 | { ID: 2, PartyAnimalName: 'Socrates' }, 9 | { ID: 3, PartyAnimalName: 'Anaximander' }, 10 | { ID: 4, PartyAnimalName: 'Anaximenes' }, 11 | { ID: 5, PartyAnimalName: 'Speusippus' }, 12 | { ID: 6, PartyAnimalName: 'Diogenes' }, 13 | { ID: 7, PartyAnimalName: 'Lycophron' }, 14 | ], 15 | recordsets: [ 16 | [ 17 | { ID: 1, PartyAnimalName: 'Plato' }, 18 | { ID: 2, PartyAnimalName: 'Socrates' }, 19 | { ID: 3, PartyAnimalName: 'Anaximander' }, 20 | { ID: 4, PartyAnimalName: 'Anaximenes' }, 21 | { ID: 5, PartyAnimalName: 'Speusippus' }, 22 | { ID: 6, PartyAnimalName: 'Diogenes' }, 23 | { ID: 7, PartyAnimalName: 'Lycophron' }, 24 | ], 25 | ], 26 | rowsAffected: [7], 27 | }; 28 | 29 | let connection; 30 | 31 | describe('query tests using promise interface', () => { 32 | beforeEach(() => { 33 | connection = new sql.ConnectionPoolParty({ 34 | dsn: { 35 | user: 'sa', 36 | password: 'PoolPartyyy9000', 37 | server: 'localhost', 38 | database: 'PoolParty', 39 | trustServerCertificate: true, 40 | encrypt: false, 41 | }, 42 | retries: 1, 43 | reconnects: 1, 44 | }); 45 | }); 46 | afterEach(() => connection.close()); 47 | it('returns expected results with explicit warmup', 48 | () => connection.warmup() 49 | .then(() => { 50 | expect(connection.pools[0].connection.connected).toEqual(true); 51 | const request = connection.request(); 52 | return request.query('select * from PartyAnimals') 53 | .then((result) => { 54 | expect(result).toEqual(queryResults); 55 | }); 56 | })); 57 | it('returns expected results with implicit warmup', () => { 58 | expect(connection.pools.length).toEqual(0); 59 | return connection.request() 60 | .query('select * from PartyAnimals') 61 | .then((result) => { 62 | expect(result).toEqual(queryResults); 63 | }); 64 | }); 65 | it('returns expected results after a reconnect', 66 | () => connection.warmup() 67 | .then(() => { 68 | expect(connection.pools[0].connection.connected).toEqual(true); 69 | // manually closing a connection to simulate failure 70 | return connection.pools[0].connection.close(); 71 | }) 72 | .then(() => { 73 | // verify connection has been manually closed 74 | expect(connection.pools[0].connection.connected).toEqual(false); 75 | return connection.request() 76 | .query('select * from PartyAnimals') 77 | .then((result) => { 78 | expect(result).toEqual(queryResults); 79 | }); 80 | })); 81 | it('rejects with an error with an unhealthy connection and 0 reconnects', 82 | () => connection.warmup() 83 | .then(() => { 84 | expect(connection.pools[0].connection.connected).toEqual(true); 85 | // manually closing a connection to simulate failure 86 | return connection.pools[0].connection.close(); 87 | }) 88 | .then(() => { 89 | // verify connection has been manually closed 90 | expect(connection.pools[0].connection.connected).toEqual(false); 91 | return connection.request({ reconnects: 0 }) 92 | .query('select * from PartyAnimals') 93 | .then( 94 | () => { expect('the promise should reject').toEqual(false); }, 95 | (err) => { 96 | expect(err).toBeInstanceOf(AggregateError); 97 | }, 98 | ); 99 | })); 100 | }); 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@godaddy/mssql-pool-party", 3 | "version": "2.0.0", 4 | "description": "Extension of mssql that provides management of multiple connection pools, dsns, retries, and more", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist/", 8 | "src/" 9 | ], 10 | "scripts": { 11 | "build": "babel src --out-dir dist --ignore test.js --source-maps --copy-files", 12 | "coverage": "open coverage/lcov-report/index.html", 13 | "docs": "jsdoc2md --configure jsdoc.json src/**/*.js > API.md", 14 | "lint": "eslint src test --fix", 15 | "test": "npm run lint && jest src/* && npm run test:coverage", 16 | "test:all": "rm -rf coverage && npm run lint && npm run test:unit && npm run test:docker && npm run test:integration && npm run test:coverage && npm run test:docker-stop", 17 | "test:coverage": "nyc report --reporter=text -t coverage --check-coverage --branches 75 --functions 75 --lines 75 --statements 75", 18 | "test:docker": "./test/start-mssql.sh", 19 | "test:docker-stop": "./test/stop-mssql.sh", 20 | "test:integration": "node --trace-warnings node_modules/.bin/jest test/parallel/* && mv coverage/coverage-final.json coverage/coverage-integration-parallel.json && node --trace-warnings node_modules/.bin/jest test/serial/index.test.js && mv coverage/coverage-final.json coverage/coverage-integration-serial.json", 21 | "test:unit": "jest src/* && mv coverage/coverage-final.json coverage/coverage-unit.json", 22 | "prepublishOnly": "npm run lint && npm run test:all && npm run docs && npm run build", 23 | "pretest": "echo 'Most of our testing is done via integration tests. See package.json for more information.'" 24 | }, 25 | "repository": "godaddy/mssql-pool-party", 26 | "keywords": [ 27 | "mssql", 28 | "sql", 29 | "failover", 30 | "dsn" 31 | ], 32 | "license": "MIT", 33 | "author": { 34 | "name": "Grant Shively", 35 | "email": "gshively@godaddy.com" 36 | }, 37 | "engines": { 38 | "node": ">=18" 39 | }, 40 | "dependencies": { 41 | "@babel/runtime": "^7.6.3", 42 | "aggregate-error": "^3.0.1", 43 | "debug": "^4.1.1", 44 | "lodash.partial": "^4.2.1", 45 | "mssql": "^11.0.1", 46 | "promise-reduce": "^2.1.0", 47 | "promise-retry": "^1.1.1", 48 | "uuid": "^3.3.3" 49 | }, 50 | "devDependencies": { 51 | "@babel/cli": "^7.19.3", 52 | "@babel/core": "^7.6.4", 53 | "@babel/preset-env": "^7.25.3", 54 | "babel-eslint": "^10.0.3", 55 | "babel-jest": "^29.3.1", 56 | "babel-plugin-add-module-exports": "^1.0.4", 57 | "eslint": "^6.6.0", 58 | "eslint-config-airbnb": "^18.0.1", 59 | "eslint-plugin-import": "^2.18.2", 60 | "eslint-plugin-jsx-a11y": "^6.2.3", 61 | "eslint-plugin-react": "^7.16.0", 62 | "execa": "^3.2.0", 63 | "jest": "^29.3.1", 64 | "jsdoc-babel": "^0.5.0", 65 | "jsdoc-to-markdown": "^7.1.1", 66 | "nyc": "^14.1.1" 67 | }, 68 | "babel": { 69 | "presets": [ 70 | "@babel/preset-env" 71 | ], 72 | "sourceMaps": "inline", 73 | "retainLines": true, 74 | "plugins": [ 75 | "add-module-exports" 76 | ], 77 | "targets": "node 18.0" 78 | }, 79 | "jest": { 80 | "collectCoverage": true, 81 | "collectCoverageFrom": [ 82 | "src/**/*.js" 83 | ], 84 | "coveragePathIgnorePatterns": [ 85 | "\\.test\\.js$" 86 | ], 87 | "coverageReporters": [ 88 | "json" 89 | ], 90 | "roots": [ 91 | "/src/", 92 | "/test/" 93 | ] 94 | }, 95 | "eslintConfig": { 96 | "extends": "airbnb", 97 | "env": { 98 | "jest": true 99 | }, 100 | "parser": "babel-eslint", 101 | "rules": { 102 | "import/prefer-default-export": 0, 103 | "no-confusing-arrow": 0, 104 | "no-underscore-dangle": 0, 105 | "operator-linebreak": [ 106 | "error", 107 | "after", 108 | { 109 | "overrides": { 110 | "?": "before", 111 | ":": "before" 112 | } 113 | } 114 | ] 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /test/parallel/stats.test.js: -------------------------------------------------------------------------------- 1 | import * as sql from '../../src'; 2 | import delay from '../delay'; 3 | 4 | const statsAfterWarmup = { 5 | pools: [{ 6 | health: { 7 | connected: true, 8 | connecting: false, 9 | lastHealAt: undefined, 10 | lastPromotionAt: undefined, 11 | healCount: 0, 12 | healthy: true, 13 | promotionCount: 0, 14 | retryCount: 0, 15 | }, 16 | config: { 17 | user: 'sa', 18 | server: 'localhost', 19 | database: 'PoolParty', 20 | id: expect.any(String), 21 | createdAt: expect.any(Number), 22 | port: 1433, 23 | appName: 'mssql-pool-party-tests', 24 | encrypt: false, 25 | readOnlyIntent: false, 26 | priority: undefined, 27 | poolMin: 0, 28 | poolMax: 10, 29 | }, 30 | timeouts: { 31 | connect: 5000, 32 | request: 30000, 33 | poolAcquire: 30000, 34 | poolCreate: 30000, 35 | poolIdle: 30000, 36 | }, 37 | }, { 38 | health: { 39 | connected: true, 40 | connecting: false, 41 | healthy: true, 42 | lastHealAt: undefined, 43 | lastPromotionAt: undefined, 44 | healCount: 0, 45 | promotionCount: 0, 46 | retryCount: 0, 47 | }, 48 | config: { 49 | user: 'sa', 50 | server: 'localhost', 51 | database: 'PoolParty', 52 | id: expect.any(String), 53 | createdAt: expect.any(Number), 54 | port: 1433, 55 | appName: 'mssql-pool-party-tests', 56 | encrypt: false, 57 | readOnlyIntent: false, 58 | priority: undefined, 59 | poolMin: 0, 60 | poolMax: 10, 61 | }, 62 | timeouts: { 63 | connect: 5000, 64 | request: 30000, 65 | poolAcquire: 30000, 66 | poolCreate: 30000, 67 | poolIdle: 30000, 68 | }, 69 | }], 70 | healing: false, 71 | warmedUp: true, 72 | reconnects: 1, 73 | reconnectCount: 0, 74 | retries: 1, 75 | }; 76 | 77 | let connection; 78 | 79 | describe('stats tests', () => { 80 | beforeEach(() => { 81 | connection = new sql.ConnectionPoolParty({ 82 | dsns: [{ 83 | user: 'sa', 84 | password: 'PoolPartyyy9000', 85 | server: 'localhost', 86 | database: 'PoolParty', 87 | encrypt: false, 88 | }, { 89 | user: 'sa', 90 | password: 'PoolPartyyy9000', 91 | server: 'localhost', 92 | database: 'PoolParty', 93 | encrypt: false, 94 | }], 95 | retries: 1, 96 | reconnects: 1, 97 | connectionPoolConfig: { 98 | connectTimeout: 5000, 99 | requestTimeout: 30000, 100 | options: { 101 | readOnlyIntent: false, 102 | encrypt: false, 103 | appName: 'mssql-pool-party-tests', 104 | }, 105 | }, 106 | }); 107 | }); 108 | afterEach(() => { 109 | connection.close(); 110 | }); 111 | it('displays expected stats after warmup', 112 | () => connection.warmup() 113 | .then(delay(100)) // need a delay due to race warmup strategy 114 | .then(() => { 115 | expect(connection.stats()).toEqual(statsAfterWarmup); 116 | })); 117 | it(`displays expected stats after the primary connection becomes unhealthy 118 | and the secondary is promoted`, () => { 119 | let primaryId; 120 | let secondaryId; 121 | return connection.warmup() 122 | .then(delay(100)) // need a delay due to race warmup strategy 123 | .then(() => { 124 | primaryId = connection.pools[0].dsn.id; 125 | secondaryId = connection.pools[1].dsn.id; 126 | return connection.pools[0].connection.close(); 127 | }) 128 | .then(() => { 129 | // confirm primary is closed 130 | expect(connection.pools[0].connection.connected).toEqual(false); 131 | expect(connection.pools[1].connection.connected).toEqual(true); 132 | return connection.request() 133 | .query('select * from PartyAnimals'); 134 | }) 135 | .then(() => { 136 | // verify the secondary was promoted 137 | expect(connection.pools[0].dsn.id).toEqual(secondaryId); 138 | expect(connection.pools[1].dsn.id).toEqual(primaryId); 139 | const stats = connection.stats(); 140 | expect(stats.pools[0].health.lastPromotionAt).toEqual(expect.any(Number)); 141 | expect(stats.pools[0].health.promotionCount).toEqual(1); 142 | expect(stats.pools[1].health.connected).toBe(false); 143 | }); 144 | }); 145 | it('displays expected stats after both pools fail a request and are healed', () => { 146 | let primaryId; 147 | let secondaryId; 148 | return connection.warmup() 149 | .then(delay(100)) // need a delay due to race warmup strategy 150 | .then(() => { 151 | primaryId = connection.pools[0].dsn.id; 152 | secondaryId = connection.pools[1].dsn.id; 153 | return Promise.all([ 154 | connection.pools[0].connection.close(), 155 | connection.pools[1].connection.close(), 156 | ]); 157 | }) 158 | .then(() => { 159 | // confirm both pools are closed 160 | expect(connection.pools[0].connection.connected).toEqual(false); 161 | expect(connection.pools[1].connection.connected).toEqual(false); 162 | return connection.request() 163 | .query('select * from PartyAnimals'); 164 | }) 165 | .then(() => { 166 | // verify the secondary was promoted 167 | expect(connection.pools[0].dsn.id).toEqual(primaryId); 168 | expect(connection.pools[1].dsn.id).toEqual(secondaryId); 169 | const stats = connection.stats(); 170 | expect(stats.pools[0].health.connected).toBe(true); 171 | expect(stats.pools[0].health.healCount).toBe(1); 172 | expect(stats.pools[0].health.lastHealAt).toEqual(expect.any(Number)); 173 | expect(stats.pools[1].health.connected).toBe(true); 174 | expect(stats.pools[1].health.healCount).toBe(1); 175 | expect(stats.pools[1].health.lastHealAt).toEqual(expect.any(Number)); 176 | }); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @godaddy/mssql-pool-party 2 | 3 | ES6+ extension of [mssql](https://github.com/patriksimek/node-mssql) that provides: 4 | 5 | - Failover between multiple connection pools 6 | - A mechanism to load DSNs ([Data Source Names](https://en.wikipedia.org/wiki/Data_source_name), think connection strings) asynchronously and recreate connection pools accordingly 7 | - Various retry options for requests, transactions, and prepared statements 8 | - Health and statistics of connection pools 9 | - Only minor changes to the existing mssql API 10 | 11 | Jump on in, the water's fine. 12 | 13 | ### Why does this package exist? 14 | 15 | - Disaster recovery option when you're not using Availability Groups (e.g. Database Mirroring) 16 | - If you store/manage database credentials using an external service, the `dsnProvider` option lets you automatically grab those changes and recreate connection pools. 17 | - Stats/health reporting, so you can see what databases your app is talking to and whether or not the connections are healthy. 18 | - Built in retry/reconnect options 19 | 20 | ### Usage 21 | 22 | #### Simple single connection pool 23 | 24 | ```js 25 | // my-db.js 26 | import sql from '@godaddy/mssql-pool-party'; 27 | 28 | const config = { 29 | // See configuration section below for more information 30 | dsn: { 31 | user: ... 32 | password: ... 33 | server: ... 34 | database: ... 35 | }, 36 | connectionPoolConfig: { 37 | options: { 38 | encrypt: true, 39 | } 40 | } 41 | }; 42 | 43 | const connection = new sql.ConnectionPoolParty(config); 44 | 45 | connection.on('error', console.error); 46 | 47 | export default connection; 48 | ``` 49 | 50 | ```js 51 | // call-proc.js 52 | import sql from '@godaddy/mssql-pool-party'; 53 | import myDb from './my-db'; 54 | 55 | export default function callProc() { 56 | return myDb.request() 57 | .input('some_param', sql.Int, 10) 58 | .execute('my_proc') 59 | .then(console.dir) 60 | .catch(console.error); 61 | } 62 | ``` 63 | 64 | #### Using a DsnProvider to retrieve one or more dsn(s) dynamically 65 | 66 | ```js 67 | // my-db.js 68 | import sql from '@godaddy/mssql-pool-party'; 69 | import jsonFileDsnProvider from 'my-example-dsn-provider'; 70 | 71 | // grab DSN(s) from a json file 72 | const dsnProvider = jsonFileDsnProvider('/etc/secrets/my_db.json'); 73 | 74 | const config = { 75 | dsnProvider, 76 | connectionPoolConfig: { 77 | connectTimeout: 5000, 78 | requestTimeout: 30000, 79 | options: { 80 | appName: 'mssql-pool-party-example', 81 | encrypt: true, 82 | }, 83 | }, 84 | retries: 2, 85 | reconnects: 1, 86 | }; 87 | 88 | const connection = new sql.ConnectionPoolParty(config); 89 | 90 | // this attempts to connect each ConnectionPool before any requests are made. 91 | // returns a promise, so you can use it during an API's warmup phase before 92 | // starting any listeners 93 | connection.warmup(); 94 | 95 | connection.on('error', console.error); 96 | 97 | // logging connection pool stats to console every 60 seconds 98 | setInterval(() => console.log(connection.stats()), 60000); 99 | 100 | export default connection; 101 | ``` 102 | 103 | ```js 104 | // run-query.js 105 | import sql from '@godaddy/mssql-pool-party'; 106 | import myDb from './my-db'; 107 | 108 | export default function runQuery(id) { 109 | return myDb.request() 110 | .input('some_param', sql.Int, id) 111 | .query('select * from mytable where id = @some_param') 112 | .then(console.dir) 113 | .catch(console.error); 114 | } 115 | ``` 116 | 117 | ### Configuration 118 | 119 | Check out the [detailed API documentation](API.md#new-connectionpoolpartyconfig). 120 | 121 | You'll also want to familiarize yourself with [`node-mssql`'s documentation](https://github.com/tediousjs/node-mssql/blob/master/README.md#documentation), as much of it applies to mssql-pool-party. 122 | 123 | ### Events 124 | 125 | - `error` - This event is fired whenever errors are encountered that DO NOT result in a rejected promise, stream error, or callback error that can be accessed/caught by the consuming app, which makes this event the only way to respond to such errors. Example: An app initiates a query, the first attempt fails, but a retry is triggered and the second attempt succeeds. From the apps perspective, the retry attempt isn't visible and their promised query is resolved. However, apps may want to know when a query requires a retry to succeed, so we emit the error using this event. **TAKE NOTE**: Unlike the mssql package, failing to subscribe to the error event will not result in an unhandled exception and subsequent process crash. 126 | 127 | ### Streaming 128 | 129 | The mssql package's streaming API is supported in mssql-pool-party, with a substantial caveat. By their nature, streams attempt to minimize the amount of memory in use by "streaming" chunks of the data from one source to another. Their behavior makes them not play well with retry/reconnect logic, because the stream destination would need to understand when a retry/reconnect happens and abandon any previously received data in preparation for data coming from another attempt. Otherwise, you're going to end up just caching all the chunks in memory, negating the benefits of using a stream. Observe: 130 | 131 | ```js 132 | import myDb from './my-db'; 133 | 134 | const request = myDb.request(); 135 | request.stream = true; 136 | let currentAttempt = 0; 137 | let rows; 138 | let errors; 139 | const resetDataIfNeeded = (attemptNumber) => { 140 | if (attemptNumber > currentAttempt) { 141 | rows = []; 142 | errors = []; 143 | currentAttempt += 1; 144 | } 145 | }; 146 | request.query('select * from SomeHugeTable'); 147 | request.on('error', (err, attemptNumber) => { 148 | resetDataIfNeeded(attemptNumber); 149 | errors.push(err); 150 | }); 151 | request.on('rows', (row, attemptNumber) => { 152 | resetDataIfNeeded(attemptNumber); 153 | rows.push(row); 154 | }); 155 | // NOTE: done is only called after the final attempt 156 | request.on('done', (result, attemptNumber) => { 157 | if (errors.length) { 158 | errors.forEach(console.log); 159 | throw new Error('Unhandled errors. Handle them!'); 160 | } 161 | console.log(rows); 162 | }); 163 | ``` 164 | 165 | In this example, we are storing errors/rows in memory. If we notice that the attemptNumber increments, we throw away our cached data and start over. Once the done event fires, we check to see if there are any errors and if not, return the results. This is a poor use of streams, because we are caching the entire result set in memory. To use streams properly, we would need to take the data provided by the `rows` event and shuttle it off somewhere else. The problem is that where we are shuttling it off to needs something similar to the `resetDataIfNeeded` function in the example above. 166 | 167 | One thing to note is mssql-pool-party does allow you to use the vanilla streaming API and avoid the concerns of juggling the attempt number, but you'll need to set `retries` and `reconnects` to 0. 168 | 169 | ### A quick note on caching 170 | 171 | When a ConnectionPoolParty is instantiated, it will internally cache one or more instance(s) of mssql.Connection. You should only create one instance of ConnectionPoolParty per set of DSNs and cache it for use in other modules (as seen in the examples above). 172 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## connection-pool-party 4 | 5 | * [connection-pool-party](#module_connection-pool-party) 6 | * [.ConnectionPoolParty](#module_connection-pool-party.ConnectionPoolParty) ⇐ EventEmitter 7 | * [new ConnectionPoolParty(config, [cb])](#new_module_connection-pool-party.ConnectionPoolParty_new) 8 | * [.warmup([cb])](#module_connection-pool-party.ConnectionPoolParty+warmup) ⇒ Promise 9 | * [.forceFqdnConnectionPoolFactory(suffix)](#module_connection-pool-party.forceFqdnConnectionPoolFactory) ⇒ Promise 10 | 11 | 12 | 13 | ### connection-pool-party.ConnectionPoolParty ⇐ EventEmitter 14 | **Kind**: static class of [connection-pool-party](#module_connection-pool-party) 15 | **Extends**: EventEmitter 16 | 17 | * [.ConnectionPoolParty](#module_connection-pool-party.ConnectionPoolParty) ⇐ EventEmitter 18 | * [new ConnectionPoolParty(config, [cb])](#new_module_connection-pool-party.ConnectionPoolParty_new) 19 | * [.warmup([cb])](#module_connection-pool-party.ConnectionPoolParty+warmup) ⇒ Promise 20 | 21 | 22 | 23 | #### new ConnectionPoolParty(config, [cb]) 24 | Class representing a ConnectionPoolParty, which manages one or more ConnectionPool instance(s). 25 | ConnectionPoolParty extends the mssql package to provide failover between ConnectionPools, 26 | reconnets/retries, and basic health/statistics reporting. 27 | 28 | 29 | | Param | Type | Default | Description | 30 | | --- | --- | --- | --- | 31 | | config | object | | Configuration for ConnectionPoolParty | 32 | | [config.reconnects] | number | 0 | The number of times a request will be retried against ALL pools. A heal operation is attempted before a reconnect. Total request attempts is calculated using: pools * (1+reconnects) * (1+retries) | 33 | | [config.retries] | number | 0 | The number of times a request will be retried against a single pool. Each pool is retried separately. Total request attempts is calculated using: pools * (1+reconnects) * (1+retries) | 34 | | [config.dsn] | object | | A single DSN, matches the configuration object expected by the mssql package. Required if dsns and dsnProvider are not provided. | 35 | | [config.dsns] | array | | An array of DSNs, each entry should match the configuraiton object expected by the mssql package. Overrides config.dsn. Required if dsn and dsnProvider are not provided. | 36 | | [config.dsnProvider] | function | | A function returning a promise that resolves with an array of dsn object(s). This option will override config.dsn and config.dsns. Required if dsn and dsns are not provided. | 37 | | [config.connectionPoolFactory] | function | | A function that receives the dsn objects from the dnsProvider and returns a promise that resolves with *connected* instance(s) of ConnectionPool. Use this option if you want to customize how mssql ConnectionPools are instantiated and connected. | 38 | | [config.connectionPoolConfig] | object | | An object containing any configuration you want to attach to the config provided when creating an mssql ConnectionPool. This is useful if you don't want to create a custom dsnProvider or connectionPoolFactory to modify the configuration used to create ConnectionPools. Just keep in mind that any config set here will override the config set in the dsnProvider. Check [node-mssql README.md](https://github.com/tediousjs/node-mssql/blob/master/README.md#general-same-for-all-drivers) for more information. | 39 | | [config.connectionPoolConfig.options] | object | | An object containing any configuration you want to pass all the way to driver used by node-mssql, e.g. appName, encrypt, etc. Check [node-mssql README.md](https://github.com/tediousjs/node-mssql/blob/master/README.md#tedious) for more information. | 40 | | [config.connectionPoolConfig.pool] | object | | An object containing any configuration you want to pass to the pool implementation internal to node-mssql, e.g. max, min, idleTimeout, etc. Check [node-mssql README.md](https://github.com/tediousjs/node-mssql/blob/master/README.md#general-same-for-all-drivers) for more information. | 41 | | [config.prioritizePools] | boolean | | A flag to enable pool prioritization behavior. If you enable this behavior, your dsns must have a numeric priority property. The lower the number, the higher the priority of the dsn, starting at 0. At a specified interval, the pools collection will be examined to see if the pools are no longer indexed in order of priority. If this is the case, the pools will be healed (if applicable) and re-ordered in terms of their priority. This is a useful behavior if you want to fail back to a "primary" dsn after it becomes healthy again. | 42 | | [config.prioritizeInterval] | number | 30000 | The interval in milliseconds to run the pool prioritization check. Setting a value below 10000 is not advised, as the pool prioritization check can take significant resources if a pool heal is required. | 43 | | [cb] | function | | Optional callback interface, providing this automatically calls warmup. It is preferable to use the Promise-based interface and call warmup explicitly. | 44 | 45 | 46 | 47 | #### connectionPoolParty.warmup([cb]) ⇒ Promise 48 | Retrieve the dsn(s) from the dsnProvider, create and connect the ConnectionPool 49 | instance(s) using the connectionPoolFactory. Returns a promise. Can be called 50 | to explicitly warmup database connections. Called implicitly when submitting 51 | any requests. After a successful warmup, subsequent calls will not warmup again. 52 | 53 | **Kind**: instance method of [ConnectionPoolParty](#module_connection-pool-party.ConnectionPoolParty) 54 | **Returns**: Promise - A promise indicating that a warmup was successful. This promise 55 | cannot reject, but errors during warmup will result in the cached warmup promise 56 | being removed, which will allow warmup to be re-attempted. 57 | 58 | | Param | Type | Description | 59 | | --- | --- | --- | 60 | | [cb] | function | An optional callback interface. It is preferable to use the Promise-based interface. | 61 | 62 | 63 | 64 | ### connection-pool-party.forceFqdnConnectionPoolFactory(suffix) ⇒ Promise 65 | This connection pool factory is only needed for a niche use case, but it 66 | serves as an example of what is possible when creating a custom 67 | connectionPoolFactory. 68 | 69 | If your dsn provider returns servers as hostnames instead of FQDNs or IPs, 70 | you may have systems that are unable to resolve the hostnames due to 71 | misconfigured DNS settings. If you are unable to fix the DNS resolution for 72 | whatever reason, and you know what the FQDN suffix is, you can use this 73 | connectionPoolFactory to add the suffix. 74 | 75 | **Kind**: static method of [connection-pool-party](#module_connection-pool-party) 76 | **Returns**: Promise - A promise that uses a dsn provided by the dsnProvider to create 77 | an mssql ConnectionPool. 78 | 79 | | Param | Type | Description | 80 | | --- | --- | --- | 81 | | suffix | string | The FQDN suffix to use if your dsn's server is provided as a hostname. | 82 | 83 | -------------------------------------------------------------------------------- /test/parallel/stream-execute.test.js: -------------------------------------------------------------------------------- 1 | import * as sql from '../../src'; 2 | 3 | const procResults = { 4 | columns: { 5 | ID: { 6 | index: 0, 7 | name: 'ID', 8 | nullable: false, 9 | caseSensitive: false, 10 | identity: true, 11 | readOnly: true, 12 | type: expect.any(Function), 13 | }, 14 | PartyAnimalName: { 15 | index: 1, 16 | name: 'PartyAnimalName', 17 | length: 65535, 18 | nullable: true, 19 | caseSensitive: false, 20 | identity: false, 21 | readOnly: false, 22 | type: expect.any(Function), 23 | }, 24 | }, 25 | output: {}, 26 | returnValue: 0, 27 | rows: [{ ID: 6, PartyAnimalName: 'Diogenes' }], 28 | rowsAffected: [], 29 | }; 30 | 31 | let connection; 32 | 33 | describe('execute (stored procedures) tests using stream interface', () => { 34 | beforeEach(() => { 35 | connection = new sql.ConnectionPoolParty({ 36 | dsn: { 37 | user: 'sa', 38 | password: 'PoolPartyyy9000', 39 | server: 'localhost', 40 | database: 'PoolParty', 41 | encrypt: false, 42 | trustServerCertificate: true, 43 | }, 44 | connectionPoolConfig: { 45 | stream: true, 46 | }, 47 | retries: 1, 48 | reconnects: 1, 49 | }); 50 | }); 51 | afterEach(() => connection.close()); 52 | it('emits expected results with explicit warmup', (done) => { 53 | let attempt = 0; 54 | let results; 55 | let errors; 56 | const setResults = (attemptNumber) => { 57 | attempt = attemptNumber; 58 | errors = []; 59 | results = { rows: [] }; 60 | }; 61 | connection.warmup() 62 | .then(() => { 63 | expect(connection.pools[0].connection.connected).toEqual(true); 64 | const request = connection.request(); 65 | request.input('ID', sql.Int, 6); 66 | request.execute('GetPartyAnimalByID'); 67 | request.on('recordset', (columns, attemptNumber) => { 68 | if (attemptNumber > attempt) { 69 | setResults(attemptNumber); 70 | } 71 | results.columns = columns; 72 | }); 73 | request.on('row', (row, attemptNumber) => { 74 | if (attemptNumber > attempt) { 75 | setResults(attemptNumber); 76 | } 77 | results.rows.push(row); 78 | }); 79 | request.on('error', (err, attemptNumber) => { 80 | if (attemptNumber > attempt) { 81 | setResults(attemptNumber); 82 | } 83 | errors.push(err); 84 | }); 85 | request.on('done', (result, attemptNumber) => { 86 | Object.assign(results, result); 87 | expect(attemptNumber).toBe(1); 88 | expect(errors.length).toBe(0); 89 | expect(results).toEqual(procResults); 90 | done(); 91 | }); 92 | }); 93 | }); 94 | it('emits expected results with implicit warmup', (done) => { 95 | let attempt = 0; 96 | let results; 97 | let errors; 98 | const setResults = (attemptNumber) => { 99 | attempt = attemptNumber; 100 | errors = []; 101 | results = { rows: [] }; 102 | }; 103 | expect(connection.pools.length).toEqual(0); 104 | const request = connection.request(); 105 | request.input('ID', sql.Int, 6); 106 | request.execute('GetPartyAnimalByID'); 107 | request.on('recordset', (columns, attemptNumber) => { 108 | if (attemptNumber > attempt) { 109 | setResults(attemptNumber); 110 | } 111 | results.columns = columns; 112 | }); 113 | request.on('row', (row, attemptNumber) => { 114 | if (attemptNumber > attempt) { 115 | setResults(attemptNumber); 116 | } 117 | results.rows.push(row); 118 | }); 119 | request.on('error', (err, attemptNumber) => { 120 | if (attemptNumber > attempt) { 121 | setResults(attemptNumber); 122 | } 123 | errors.push(err); 124 | }); 125 | request.on('done', (result, attemptNumber) => { 126 | Object.assign(results, result); 127 | expect(attemptNumber).toBe(1); 128 | expect(errors.length).toBe(0); 129 | expect(results).toEqual(procResults); 130 | done(); 131 | }); 132 | }); 133 | it('emits expected results after a reconnect', (done) => { 134 | let attempt = 0; 135 | let results; 136 | let errors; 137 | const setResults = (attemptNumber) => { 138 | attempt = attemptNumber; 139 | errors = []; 140 | results = { rows: [] }; 141 | }; 142 | connection.warmup() 143 | .then(() => { 144 | expect(connection.pools[0].connection.connected).toEqual(true); 145 | // manually closing a connection to simulate failure 146 | return connection.pools[0].connection.close(); 147 | }) 148 | .then(() => { 149 | // verify connection has been manually closed 150 | expect(connection.pools[0].connection.connected).toEqual(false); 151 | const request = connection.request(); 152 | request.input('ID', sql.Int, 6); 153 | request.execute('GetPartyAnimalByID'); 154 | request.on('recordset', (columns, attemptNumber) => { 155 | if (attemptNumber > attempt) { 156 | setResults(attemptNumber); 157 | } 158 | results.columns = columns; 159 | }); 160 | request.on('row', (row, attemptNumber) => { 161 | if (attemptNumber > attempt) { 162 | setResults(attemptNumber); 163 | } 164 | results.rows.push(row); 165 | }); 166 | request.on('error', (err, attemptNumber) => { 167 | if (attemptNumber > attempt) { 168 | setResults(attemptNumber); 169 | } 170 | errors.push(err); 171 | }); 172 | request.on('done', (result, attemptNumber) => { 173 | Object.assign(results, result); 174 | expect(attemptNumber).toBe(2); 175 | expect(errors.length).toBe(0); 176 | expect(results).toEqual(procResults); 177 | done(); 178 | }); 179 | }); 180 | }); 181 | it('emits with expected error for an unhealthy connection and 0 reconnects', (done) => { 182 | let attempt = 0; 183 | let results; 184 | let errors; 185 | const setResults = (attemptNumber) => { 186 | attempt = attemptNumber; 187 | errors = []; 188 | results = { rows: [] }; 189 | }; 190 | connection.close(); 191 | connection = new sql.ConnectionPoolParty({ 192 | dsn: { 193 | user: 'sa', 194 | password: 'PoolPartyyy9000', 195 | server: 'localhost', 196 | database: 'PoolParty', 197 | trustServerCertificate: true, 198 | encrypt: false, 199 | }, 200 | connectionPoolConfig: { 201 | stream: true, 202 | }, 203 | retries: 1, 204 | reconnects: 0, 205 | }); 206 | connection.warmup() 207 | .then(() => { 208 | expect(connection.pools[0].connection.connected).toEqual(true); 209 | // manually closing a connection to simulate failure 210 | return connection.pools[0].connection.close(); 211 | }) 212 | .then(() => { 213 | // verify connection has been manually closed 214 | expect(connection.pools[0].connection.connected).toEqual(false); 215 | const request = connection.request(); 216 | request.input('ID', sql.Int, 6); 217 | request.execute('GetPartyAnimalByID'); 218 | request.on('recordset', (columns, attemptNumber) => { 219 | if (attemptNumber > attempt) { 220 | setResults(attemptNumber); 221 | } 222 | results.columns = columns; 223 | }); 224 | request.on('row', (row, attemptNumber) => { 225 | if (attemptNumber > attempt) { 226 | setResults(attemptNumber); 227 | } 228 | results.rows.push(row); 229 | }); 230 | request.on('error', (err, attemptNumber) => { 231 | if (attemptNumber > attempt) { 232 | setResults(attemptNumber); 233 | } 234 | errors.push(err); 235 | }); 236 | request.on('done', (result, attemptNumber) => { 237 | expect(attemptNumber).toBe(1); 238 | expect(errors.length).toBe(1); 239 | done(); 240 | }); 241 | }); 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /test/parallel/stream-query.test.js: -------------------------------------------------------------------------------- 1 | import * as sql from '../../src'; 2 | 3 | const queryResults = { 4 | columns: { 5 | ID: { 6 | index: 0, 7 | name: 'ID', 8 | nullable: false, 9 | caseSensitive: false, 10 | identity: true, 11 | readOnly: true, 12 | type: expect.any(Function), 13 | }, 14 | PartyAnimalName: { 15 | index: 1, 16 | name: 'PartyAnimalName', 17 | length: 65535, 18 | nullable: true, 19 | caseSensitive: false, 20 | identity: false, 21 | readOnly: false, 22 | type: expect.any(Function), 23 | }, 24 | }, 25 | output: {}, 26 | rows: [ 27 | { ID: 1, PartyAnimalName: 'Plato' }, 28 | { ID: 2, PartyAnimalName: 'Socrates' }, 29 | { ID: 3, PartyAnimalName: 'Anaximander' }, 30 | { ID: 4, PartyAnimalName: 'Anaximenes' }, 31 | { ID: 5, PartyAnimalName: 'Speusippus' }, 32 | { ID: 6, PartyAnimalName: 'Diogenes' }, 33 | { ID: 7, PartyAnimalName: 'Lycophron' }, 34 | ], 35 | rowsAffected: [7], 36 | }; 37 | 38 | let connection; 39 | 40 | describe('query tests using stream interface', () => { 41 | beforeEach(() => { 42 | connection = new sql.ConnectionPoolParty({ 43 | dsn: { 44 | user: 'sa', 45 | password: 'PoolPartyyy9000', 46 | server: 'localhost', 47 | database: 'PoolParty', 48 | trustServerCertificate: true, 49 | encrypt: false, 50 | }, 51 | connectionPoolConfig: { 52 | stream: true, 53 | }, 54 | retries: 1, 55 | reconnects: 1, 56 | }); 57 | }); 58 | afterEach(() => connection.close()); 59 | it('returns expected results with explicit warmup', (done) => { 60 | let attempt = 0; 61 | let results; 62 | let errors; 63 | const setResults = (attemptNumber) => { 64 | attempt = attemptNumber; 65 | errors = []; 66 | results = { rows: [] }; 67 | }; 68 | connection.warmup() 69 | .then(() => { 70 | expect(connection.pools[0].connection.connected).toEqual(true); 71 | const request = connection.request(); 72 | request.query('select * from PartyAnimals'); 73 | request.on('recordset', (columns, attemptNumber) => { 74 | if (attemptNumber > attempt) { 75 | setResults(attemptNumber); 76 | } 77 | results.columns = columns; 78 | }); 79 | request.on('row', (row, attemptNumber) => { 80 | if (attemptNumber > attempt) { 81 | setResults(attemptNumber); 82 | } 83 | results.rows.push(row); 84 | }); 85 | request.on('error', (err, attemptNumber) => { 86 | if (attemptNumber > attempt) { 87 | setResults(attemptNumber); 88 | } 89 | errors.push(err); 90 | }); 91 | request.on('done', (result, attemptNumber) => { 92 | Object.assign(results, result); 93 | expect(attemptNumber).toBe(1); 94 | expect(errors.length).toBe(0); 95 | expect(results).toEqual(queryResults); 96 | done(); 97 | }); 98 | }); 99 | }); 100 | it('returns expected results with implicit warmup', (done) => { 101 | expect(connection.pools.length).toEqual(0); 102 | let attempt = 0; 103 | let results; 104 | let errors; 105 | const setResults = (attemptNumber) => { 106 | attempt = attemptNumber; 107 | errors = []; 108 | results = { rows: [] }; 109 | }; 110 | const request = connection.request(); 111 | request.query('select * from PartyAnimals'); 112 | request.on('recordset', (columns, attemptNumber) => { 113 | if (attemptNumber > attempt) { 114 | setResults(attemptNumber); 115 | } 116 | results.columns = columns; 117 | }); 118 | request.on('row', (row, attemptNumber) => { 119 | if (attemptNumber > attempt) { 120 | setResults(attemptNumber); 121 | } 122 | results.rows.push(row); 123 | }); 124 | request.on('error', (err, attemptNumber) => { 125 | if (attemptNumber > attempt) { 126 | setResults(attemptNumber); 127 | } 128 | errors.push(err); 129 | }); 130 | request.on('done', (result, attemptNumber) => { 131 | Object.assign(results, result); 132 | expect(attemptNumber).toBe(1); 133 | expect(errors.length).toBe(0); 134 | expect(results).toEqual(queryResults); 135 | done(); 136 | }); 137 | }); 138 | it('ends up with expected results after done event is emitted after a reconnect', (done) => { 139 | let attempt = 0; 140 | let results; 141 | let errors; 142 | const setResults = (attemptNumber) => { 143 | attempt = attemptNumber; 144 | errors = []; 145 | results = { rows: [] }; 146 | }; 147 | connection.warmup() 148 | .then(() => { 149 | expect(connection.pools[0].connection.connected).toEqual(true); 150 | // manually closing a connection to simulate failure 151 | return connection.pools[0].connection.close(); 152 | }) 153 | .then(() => { 154 | // verify connection has been manually closed 155 | expect(connection.pools[0].connection.connected).toEqual(false); 156 | const request = connection.request(); 157 | request.query('select * from PartyAnimals'); 158 | request.on('recordset', (columns, attemptNumber) => { 159 | if (attemptNumber > attempt) { 160 | setResults(attemptNumber); 161 | } 162 | results.columns = columns; 163 | }); 164 | request.on('row', (row, attemptNumber) => { 165 | if (attemptNumber > attempt) { 166 | setResults(attemptNumber); 167 | } 168 | results.rows.push(row); 169 | }); 170 | request.on('error', (err, attemptNumber) => { 171 | if (attemptNumber > attempt) { 172 | setResults(attemptNumber); 173 | } 174 | errors.push(err); 175 | }); 176 | request.on('done', (result, attemptNumber) => { 177 | Object.assign(results, result); 178 | expect(attemptNumber).toBe(2); 179 | expect(errors.length).toBe(0); 180 | expect(results).toEqual(queryResults); 181 | done(); 182 | }); 183 | }); 184 | }); 185 | it('ends up with expected error after done event for an unhealthy connection and 0 reconnects', (done) => { 186 | let attempt = 0; 187 | let results; 188 | let errors; 189 | const setResults = (attemptNumber) => { 190 | attempt = attemptNumber; 191 | errors = []; 192 | results = { rows: [] }; 193 | }; 194 | connection.close(); 195 | connection = new sql.ConnectionPoolParty({ 196 | dsn: { 197 | user: 'sa', 198 | password: 'PoolPartyyy9000', 199 | server: 'localhost', 200 | database: 'PoolParty', 201 | trustServerCertificate: true, 202 | encrypt: false, 203 | }, 204 | connectionPoolConfig: { 205 | stream: true, 206 | }, 207 | retries: 1, 208 | reconnects: 0, 209 | }); 210 | connection.warmup() 211 | .then(() => { 212 | expect(connection.pools[0].connection.connected).toEqual(true); 213 | // manually closing a connection to simulate failure 214 | return connection.pools[0].connection.close(); 215 | }) 216 | .then(() => { 217 | // verify connection has been manually closed 218 | expect(connection.pools[0].connection.connected).toEqual(false); 219 | const request = connection.request({ reconnects: 0 }); 220 | request.query('select * from PartyAnimals'); 221 | request.on('recordset', (columns, attemptNumber) => { 222 | if (attemptNumber > attempt) { 223 | setResults(attemptNumber); 224 | } 225 | results.columns = columns; 226 | }); 227 | request.on('row', (row, attemptNumber) => { 228 | if (attemptNumber > attempt) { 229 | setResults(attemptNumber); 230 | } 231 | results.rows.push(row); 232 | }); 233 | request.on('error', (err, attemptNumber) => { 234 | if (attemptNumber > attempt) { 235 | setResults(attemptNumber); 236 | } 237 | errors.push(err); 238 | }); 239 | request.on('done', (result, attemptNumber) => { 240 | expect(attemptNumber).toBe(1); 241 | expect(errors.length).toBe(1); 242 | done(); 243 | }); 244 | }); 245 | }); 246 | }); 247 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.0.0 (August 20, 2024) 2 | 3 | ## Breaking 4 | 5 | - `mssql` dependency has been upgraded from 9.x to 11.x. 6 | - The minimum Node.js version is now 18.x. 7 | 8 | # 1.0.0 (October 12, 2022) 9 | 10 | ## Breaking 11 | 12 | - `mssql` dependency has been upgraded from 6.x to 9.x. See the [changelog](https://github.com/tediousjs/node-mssql/blob/master/CHANGELOG.txt) for that package for a list of changes that may be required to your configuration options. 13 | - Node >=14 is now required 14 | 15 | Additionally, the package is now published as `@godaddy/mssql-pool-party` 16 | 17 | # 0.5.2 (January 10, 2020) 18 | 19 | ## Features 20 | 21 | - defaultConnectionPoolFactory is now exported at the root of the module, making it easier to create a custom connection pool factory that extends the default behavior. 22 | 23 | ## Security Fixes 24 | 25 | - Cleaned up some npm audit errors (all on devDependencies). 26 | 27 | # 0.5.1 (November 4, 2019) 28 | 29 | ## Bug Fixes 30 | 31 | - Added missing `@babel/runtime` dependency. `@babel/plugin-transform-runtime` was added in `0.5.0`, which expects `@babel/runtime` as a runtime dependency. 32 | 33 | # 0.5.0 (November 4, 2019) 34 | 35 | No known breaking changes, but we're being cautious with a major version bump since so many packages were updated. 36 | 37 | ## Maintenance 38 | 39 | - Updated all packages to latest 40 | - Linting fixes 41 | 42 | ## Bug Fixes 43 | 44 | - Fixed error reporting on streams (not all errors were being emitted) 45 | 46 | # 0.4.0 (March 11, 2019) 47 | 48 | ## Features 49 | 50 | - New `healthy` property in pool stats which indicates whether or not a pool was capable of serving requests _since the last request was served_. This means that the secondary pool could show as healthy but actually be unhealthy because it hasn't served any requests recently. If the primary pool shows as healthy, it's likely in a healthy state if it's receiving any semblance of traffic. 51 | - New `acquireTimeoutMillis` and `createTimeoutMillis` properties added to pool stats. These are the timeouts used by the `tarn` pool inside the `mssql` package. 52 | 53 | ## Bug Fixes 54 | 55 | - No more stuck requests, resource churn, and broken failover when a database goes down. `0.3.*` suffered from a bug in the `mssql` package due to some unexpected/broken behavior in `generic-pool`. `mssql` was bumped to `6.0.0-alpha.2`, which replaced `generic-pool` with `tarn`. 56 | - The `lastPromotionAt` and `lastHealAt` stats should propagate to new pools after a heal. 57 | 58 | ## Breaking Changes 59 | 60 | - Bumped `mssql` from `^4.3.0` to `6.0.0-alpha.2`. We do not believe there are any breaking changes in `mssql-pool-party` for this upgrade, but the `mssql` bump brings with it bumps in the `tedious` driver and other internal changes which could be impacting. That, coupled with the alpha state of `mssql` means we're doing a major bump to be safe. 61 | 62 | ## Other 63 | 64 | - Improved integration tests to handle failover, healing, and promotion scenarios by introducing a second mssql container. 65 | 66 | # 0.3.2 (January 28, 2019) 67 | 68 | ## Bug Fixes 69 | 70 | - Properly close unhealthy connections ([PR #7](https://github.com/godaddy/mssql-pool-party/pull/7), thanks @DullReferenceException!) 71 | 72 | # 0.3.1 (January 8, 2019) 73 | 74 | ## Features 75 | 76 | - Upgrade node-mssql to `^4.3.0`. 77 | 78 | # 0.3.0 (September 17, 2018) 79 | 80 | ## Features 81 | 82 | - Upgrade node-mssql to `^4.2.1`. This paves the way for supporting Availability Groups (more testing needed on this front). 83 | - Better DEBUG logging 84 | - Added the following properties to the stats output: `config.appName`, `config.encrypt`. 85 | 86 | ## Bug Fixes 87 | 88 | - Added workaround for https://github.com/tediousjs/node-mssql/issues/705 until it's fixed upstream. This isn't a bug in a previous version, but in node-mssql v4. 89 | 90 | ## Breaking Changes 91 | 92 | - Since mssql-pool-party is a wrapper around node-mssql that aims to minimize interface changes, all breaking changes in node-mssql v4 are relevant to this new version, [see here for those changes](https://github.com/tediousjs/node-mssql/blob/7f374a8d73b00b17aa5b5ea5621c4314fc6e2daa/README.md#3x-to-4x-changes). 93 | - All responses from Request methods (query, execute, etc), for each style (promise, cb, stream), have been unified to match the changes made in v4. They all return a single object (and an error, if applicable). The result object will look something like the following (first example applies to callback/promise/async-await, second example applies to stream). See README.md for richer documentation. 94 | ``` 95 | { 96 | output: {}, 97 | recordset: [{}], 98 | recordsets: [ 99 | [{}], 100 | ], 101 | returnValue: 0, 102 | rowsAffected: [], 103 | } 104 | ``` 105 | ``` 106 | { 107 | columns: { 108 | ColumnName: {}, 109 | }, 110 | output: {}, 111 | returnValue: 0, 112 | rows: [{}], 113 | rowsAffected: [], 114 | } 115 | ``` 116 | - Removed the following properties from the stats output: `config.driver`, `config.tdsVersion`, `timeouts.cancel`, 117 | - Enable the `encrypt` driver options by default. Disabled by default has already been deprecated in `tedious` and will be removed in a newer version, we're just jumping ahead a little to get rid of an annoying console message. If you don't want encryption, use this config: 118 | ``` 119 | connectionPoolConfig: { 120 | options: { 121 | encrypt: false 122 | } 123 | } 124 | ``` 125 | 126 | ## Other 127 | 128 | - Upgrade jest and babel-jest from `^19.02` to `^23.6.0` 129 | - Removed jest-environment-node-debug and jest-unit from devDependencies 130 | - Enabled test coverage, dropped requirement to 75% (goal of 100% in future) 131 | - `sqlcmd` is now on the PATH inside the integration test Docker container 132 | - Fixed some bugs involved with running tests in parallel 133 | - Added missing stream tests 134 | - Added package-lock.json 135 | 136 | # 0.2.4 (November 13th, 2017) 137 | 138 | ## Bug Fixes 139 | 140 | - Fixed a bug in the default raceWarmup strategy that was incorrectly resolving the warmup if a pool failed to connect properly. This should make initial connections a little more reliable. 141 | 142 | # 0.2.3 (November 10th, 2017) 143 | 144 | ## Features 145 | 146 | - Added priority to stats output. 147 | 148 | # 0.2.2 (November 9th, 2017) 149 | 150 | ## Bug Fixes 151 | 152 | - Fixed a bug in the reconnect process that was uncovered by the bug fix in 0.2.1. In 0.2.1, we started detecting pool healing failures correctly, which started triggering a condition in the reconnect process that prevented a reconnect if no pools were healed, which turned out not to be desirable. Now, if no pools were healed during a reconnect, it jumps right to another heal attempt. 153 | 154 | # 0.2.1 (November 9th, 2017) 155 | 156 | ## Features 157 | 158 | - Added optional pool prioritization. This feature is intended to provide a means to failback to a preferred DSN after previously promoting a lower priority DSN. [See here](API.md#new-connectionpoolpartyconfig-cb) for configuration options. 159 | 160 | ## Bug Fixes 161 | 162 | - Fixed a bug in the heal process that was preventing heal failures from being detected properly in most cases. This will prevent unnecessary retries against unhealthy pools. 163 | 164 | # 0.2.0 (August 14th, 2017) 165 | 166 | ## Breaking Change 167 | 168 | - The `pool` property has been removed from the `PoolError` class due to concerns about exposing references to internal connections and DSNs. There is now a `dsn` property which contains information about the DSN owned by the pool involved in the error. 169 | 170 | # 0.1.6 (August 3rd, 2017) 171 | 172 | ## Other 173 | 174 | - Version bump with no change in functionality. This was done to avoid conflits with a previous version hosted on our internal registry. 175 | 176 | # 0.1.5 (July 21th, 2017) 177 | 178 | ## Other 179 | 180 | - Improved debug logs 181 | - All debug logs go to stdout 182 | - No changes in production functionality 183 | 184 | # 0.1.4 (June 27th, 2017) 185 | 186 | ## Bug Fixes 187 | 188 | - Fixed error when running `connection.request().query('...')` commands that did not return a recordset (e.g. `TRUNCATE TABLE ...`) 189 | 190 | ## Other 191 | 192 | - Added a high-write integration test 193 | - Added unit test 194 | - Added a bit more debug logging 195 | 196 | # 0.1.3 (April 5th, 2017) 197 | 198 | ## Bug Fixes 199 | 200 | - Removed pool.dsn.password from PoolError to avoid passwords showing up in error logging 201 | 202 | # 0.1.2 (April 3rd, 2017) 203 | 204 | ## Bug Fixes 205 | 206 | - Fixed returnValue not being return when calling execute 207 | - Fixed broken execute callback tests 208 | 209 | # 0.1.1 (April 3rd, 2017) 210 | 211 | ## Features 212 | 213 | - Added `connectionPoolConfig` option to the ConnectionPoolParty constructor. This new option lets you set configuration options that will be passed to the mssql ConnectionPool constructor. This provides a way to set things like timeouts without having to create a custom connectionPoolFactory or dsnProvider. _Note: any options set here will override options created by the dsnProvider_ 214 | 215 | # 0.1.0 (April 3rd, 2017) 216 | 217 | First Release! 218 | -------------------------------------------------------------------------------- /test/parallel/prioritized-pools.test.js: -------------------------------------------------------------------------------- 1 | import * as sql from '../../src'; 2 | import serialWarmupStrategy from '../../src/serial-warmup-strategy'; 3 | import delay from '../delay'; 4 | 5 | let connection; 6 | const realSetTimeout = setTimeout; 7 | jest.useFakeTimers(); 8 | 9 | // These tests are broken due to the changed behaviors of fake timers in Jest. 10 | describe.skip('prioritized pools tests', () => { 11 | afterEach(() => connection.close()); 12 | it(`lower priority pool starts out as the primary but is replaced by a higher priority 13 | pool after a prioritize cycle completes`, () => { 14 | connection = new sql.ConnectionPoolParty({ 15 | // the dsns are the same, but are sufficient for these tests 16 | dsns: [ 17 | { 18 | user: 'sa', 19 | password: 'PoolPartyyy9000', 20 | server: 'localhost', 21 | database: 'PoolParty', 22 | priority: 1, 23 | trustServerCertificate: true, 24 | encrypt: false, 25 | }, 26 | { 27 | user: 'sa', 28 | password: 'PoolPartyyy9000', 29 | server: 'localhost', 30 | database: 'PoolParty', 31 | priority: 0, 32 | trustServerCertificate: true, 33 | encrypt: false, 34 | }, 35 | ], 36 | prioritizePools: true, 37 | // this helps prevent warmup from resolving before all the pools are created 38 | warmupStrategy: serialWarmupStrategy, 39 | }); 40 | return connection.warmup() 41 | .then(() => { 42 | // confirm priority1 pool is first and priority0 is second 43 | expect(connection.pools[0].dsn.priority).toEqual(1); 44 | expect(connection.pools[1].dsn.priority).toEqual(0); 45 | // confirm both pools are connected 46 | expect(connection.pools[0].connection.connected).toEqual(true); 47 | expect(connection.pools[1].connection.connected).toEqual(true); 48 | // force a prioritize cycle 49 | jest.runOnlyPendingTimers(); 50 | }) 51 | // need to wait for the heal to finish during the prioritize cycle 52 | .then(delay(1000, realSetTimeout)) 53 | .then(() => { 54 | // confirm priority1 pool is now second and priority0 is now first 55 | expect(connection.pools[0].dsn.priority).toEqual(0); 56 | expect(connection.pools[1].dsn.priority).toEqual(1); 57 | }); 58 | }); 59 | it(`lower priority pool starts out as the primary. higher priority pool is unhealthy. 60 | the higher priority pool is healed and placed as primary after a prioritize cycle 61 | completes.`, () => { 62 | connection = new sql.ConnectionPoolParty({ 63 | // the dsns are the same, but are sufficient for these tests 64 | dsns: [ 65 | { 66 | user: 'sa', 67 | password: 'PoolPartyyy9000', 68 | server: 'localhost', 69 | database: 'PoolParty', 70 | priority: 1, 71 | trustServerCertificate: true, 72 | encrypt: false, 73 | }, 74 | { 75 | user: 'sa', 76 | password: 'PoolPartyyy9000', 77 | server: 'localhost', 78 | database: 'PoolParty', 79 | priority: 0, 80 | trustServerCertificate: true, 81 | encrypt: false, 82 | }, 83 | ], 84 | prioritizePools: true, 85 | // this helps prevent warmup from resolving before all the pools are created 86 | warmupStrategy: serialWarmupStrategy, 87 | }); 88 | return connection.warmup() 89 | .then(() => { 90 | // confirm priority1 pool is first and priority0 is second 91 | expect(connection.pools[0].dsn.priority).toEqual(1); 92 | expect(connection.pools[1].dsn.priority).toEqual(0); 93 | // confirm both pools are connected 94 | expect(connection.pools[0].connection.connected).toEqual(true); 95 | expect(connection.pools[1].connection.connected).toEqual(true); 96 | // close the priority0 pool 97 | return connection.pools[1].connection.close(); 98 | }) 99 | .then(() => { 100 | // confirm priority0 is closed and priority1 is open 101 | expect(connection.pools[0].connection.connected).toEqual(true); 102 | expect(connection.pools[1].connection.connected).toEqual(false); 103 | // force a prioritize cycle 104 | // jest.runOnlyPendingTimers(); 105 | }) 106 | // need to wait for the heal to finish during the prioritize cycle 107 | .then(delay(1000, realSetTimeout)) 108 | .then(() => { 109 | // confirm priority1 pool is now second and priority0 is now first 110 | // and that they are both connected 111 | expect(connection.pools[0].dsn.priority).toEqual(0); 112 | expect(connection.pools[1].dsn.priority).toEqual(1); 113 | expect(connection.pools[0].connection.connected).toEqual(true); 114 | expect(connection.pools[1].connection.connected).toEqual(true); 115 | }); 116 | }); 117 | it('does not reprioritize if all higher priority pools cannot be healed', () => { 118 | connection = new sql.ConnectionPoolParty({ 119 | // the dsns are the same, but are sufficient for these tests 120 | dsns: [ 121 | { 122 | user: 'sa', 123 | password: 'PoolPartyyy9000', 124 | server: 'localhost', 125 | database: 'PoolParty', 126 | priority: 1, 127 | trustServerCertificate: true, 128 | encrypt: false, 129 | }, 130 | { 131 | user: 'sa', 132 | password: 'wrong password', 133 | server: 'localhost', 134 | database: 'PoolParty', 135 | priority: 0, 136 | trustServerCertificate: true, 137 | encrypt: false, 138 | }, 139 | ], 140 | prioritizePools: true, 141 | // this helps prevent warmup from resolving before all the pools are created 142 | warmupStrategy: serialWarmupStrategy, 143 | }); 144 | return connection.warmup() 145 | .then(() => { 146 | // confirm priority1 pool is first and priority0 is second 147 | expect(connection.pools[0].dsn.priority).toEqual(1); 148 | expect(connection.pools[1].dsn.priority).toEqual(0); 149 | // confirm priority1 pool is healthy but priority0 is not 150 | expect(connection.pools[0].connection.connected).toEqual(true); 151 | expect(connection.pools[1].connection.connected).toEqual(false); 152 | jest.runOnlyPendingTimers(); 153 | }) 154 | // need to wait for the heal to finish during the prioritize cycle 155 | .then(delay(1000, realSetTimeout)) 156 | .then(() => { 157 | // confirm priority1 pool is still primary 158 | expect(connection.pools[0].dsn.priority).toEqual(1); 159 | expect(connection.pools[1].dsn.priority).toEqual(0); 160 | expect(connection.pools[0].connection.connected).toEqual(true); 161 | expect(connection.pools[1].connection.connected).toEqual(false); 162 | }); 163 | }); 164 | it(`if only some of the unhealthy pools are healed, the ones that remain 165 | unhealthy will not be prioritized`, () => { 166 | connection = new sql.ConnectionPoolParty({ 167 | // the dsns are the same, but are sufficient for these tests 168 | dsns: [ 169 | { 170 | user: 'sa', 171 | password: 'PoolPartyyy9000', 172 | server: 'localhost', 173 | database: 'PoolParty', 174 | priority: 2, 175 | trustServerCertificate: true, 176 | encrypt: false, 177 | }, 178 | { 179 | user: 'sa', 180 | password: 'wrong password', 181 | server: 'localhost', 182 | database: 'PoolParty', 183 | priority: 0, 184 | trustServerCertificate: true, 185 | encrypt: false, 186 | }, 187 | { 188 | user: 'sa', 189 | password: 'PoolPartyyy9000', 190 | server: 'localhost', 191 | database: 'PoolParty', 192 | priority: 1, 193 | trustServerCertificate: true, 194 | encrypt: false, 195 | }, 196 | ], 197 | prioritizePools: true, 198 | // this helps prevent warmup from resolving before all the pools are created 199 | warmupStrategy: serialWarmupStrategy, 200 | }); 201 | return connection.warmup() 202 | .then(() => { 203 | // confirm priority2 pool is first, priority0 is second, and priority1 is third 204 | expect(connection.pools[0].dsn.priority).toEqual(2); 205 | expect(connection.pools[1].dsn.priority).toEqual(0); 206 | expect(connection.pools[2].dsn.priority).toEqual(1); 207 | // confirm the priority0 pool is disconnected 208 | expect(connection.pools[0].connection.connected).toEqual(true); 209 | expect(connection.pools[1].connection.connected).toEqual(false); 210 | expect(connection.pools[2].connection.connected).toEqual(true); 211 | return connection.pools[2].connection.close(); 212 | }) 213 | .then(() => { 214 | // confirm priority0 and priority1 pools are disconnected 215 | expect(connection.pools[0].connection.connected).toEqual(true); 216 | expect(connection.pools[1].connection.connected).toEqual(false); 217 | expect(connection.pools[2].connection.connected).toEqual(false); 218 | jest.runOnlyPendingTimers(); 219 | }) 220 | // need to wait for the heal to finish during the prioritize cycle 221 | .then(delay(1000, realSetTimeout)) 222 | .then(() => { 223 | // confirm that priority1 was reprioritized but priority0 was not 224 | // because it is still disconnected 225 | expect(connection.pools[0].dsn.priority).toEqual(1); 226 | expect(connection.pools[1].dsn.priority).toEqual(2); 227 | expect(connection.pools[2].dsn.priority).toEqual(0); 228 | expect(connection.pools[0].connection.connected).toEqual(true); 229 | expect(connection.pools[1].connection.connected).toEqual(true); 230 | expect(connection.pools[2].connection.connected).toEqual(false); 231 | }); 232 | }); 233 | it(`does not heal pools with a lower priority than the primary during a prioritize 234 | cycle`, () => { 235 | connection = new sql.ConnectionPoolParty({ 236 | // the dsns are the same, but are sufficient for these tests 237 | dsns: [ 238 | { 239 | user: 'sa', 240 | password: 'PoolPartyyy9000', 241 | server: 'localhost', 242 | database: 'PoolParty', 243 | priority: 1, 244 | trustServerCertificate: true, 245 | encrypt: false, 246 | }, 247 | { 248 | user: 'sa', 249 | password: 'PoolPartyyy9000', 250 | server: 'localhost', 251 | database: 'PoolParty', 252 | priority: 0, 253 | trustServerCertificate: true, 254 | encrypt: false, 255 | }, 256 | { 257 | user: 'sa', 258 | password: 'PoolPartyyy9000', 259 | server: 'localhost', 260 | database: 'PoolParty', 261 | priority: 2, 262 | trustServerCertificate: true, 263 | encrypt: false, 264 | }, 265 | ], 266 | prioritizePools: true, 267 | // this helps prevent warmup from resolving before all the pools are created 268 | warmupStrategy: serialWarmupStrategy, 269 | }); 270 | return connection.warmup() 271 | .then(() => { 272 | // confirm priority1 pool is first, priority0 is second, and priority2 is third 273 | expect(connection.pools[0].dsn.priority).toEqual(1); 274 | expect(connection.pools[1].dsn.priority).toEqual(0); 275 | expect(connection.pools[2].dsn.priority).toEqual(2); 276 | // confirm all pools are connected 277 | expect(connection.pools[0].connection.connected).toEqual(true); 278 | expect(connection.pools[1].connection.connected).toEqual(true); 279 | expect(connection.pools[2].connection.connected).toEqual(true); 280 | // close the priority0 pool 281 | return connection.pools[1].connection.close(); 282 | }) 283 | // also close priority2 pool 284 | .then(() => connection.pools[2].connection.close()) 285 | .then(() => { 286 | // confirm priority0/2 are closed and priority1 is open 287 | expect(connection.pools[0].connection.connected).toEqual(true); 288 | expect(connection.pools[1].connection.connected).toEqual(false); 289 | expect(connection.pools[2].connection.connected).toEqual(false); 290 | // force a prioritize cycle 291 | jest.runOnlyPendingTimers(); 292 | }) 293 | // need to wait for the heal to finish during the prioritize cycle 294 | .then(delay(1000, realSetTimeout)) 295 | .then(() => { 296 | // confirm priority1 pool is now second, priority0 is now first, and 297 | // priority2 is still third 298 | // confirm that priority0 is healed and now connected, but not priority2 299 | expect(connection.pools[0].dsn.priority).toEqual(0); 300 | expect(connection.pools[1].dsn.priority).toEqual(1); 301 | expect(connection.pools[2].dsn.priority).toEqual(2); 302 | expect(connection.pools[0].connection.connected).toEqual(true); 303 | expect(connection.pools[1].connection.connected).toEqual(true); 304 | expect(connection.pools[2].connection.connected).toEqual(false); 305 | }); 306 | }); 307 | it('does not reprioritize if dsns do not specify a priority', () => { 308 | connection = new sql.ConnectionPoolParty({ 309 | // the dsns are the same, but are sufficient for these tests 310 | dsns: [ 311 | { 312 | id: 'pool1', 313 | user: 'sa', 314 | password: 'PoolPartyyy9000', 315 | server: 'localhost', 316 | database: 'PoolParty', 317 | trustServerCertificate: true, 318 | encrypt: false, 319 | }, 320 | { 321 | id: 'pool2', 322 | user: 'sa', 323 | password: 'PoolPartyyy9000', 324 | server: 'localhost', 325 | database: 'PoolParty', 326 | trustServerCertificate: true, 327 | encrypt: false, 328 | }, 329 | ], 330 | prioritizePools: true, 331 | // this helps prevent warmup from resolving before all the pools are created 332 | warmupStrategy: serialWarmupStrategy, 333 | }); 334 | return connection.warmup() 335 | .then(() => { 336 | // confirm initial order of pools 337 | expect(connection.pools[0].dsn.id).toEqual('pool1'); 338 | expect(connection.pools[1].dsn.id).toEqual('pool2'); 339 | // confirm pools are connected 340 | expect(connection.pools[0].connection.connected).toEqual(true); 341 | expect(connection.pools[1].connection.connected).toEqual(true); 342 | jest.runOnlyPendingTimers(); 343 | }) 344 | .then(() => { 345 | // confirm initial order of pools 346 | expect(connection.pools[0].dsn.id).toEqual('pool1'); 347 | expect(connection.pools[1].dsn.id).toEqual('pool2'); 348 | // confirm pools are connected 349 | expect(connection.pools[0].connection.connected).toEqual(true); 350 | expect(connection.pools[1].connection.connected).toEqual(true); 351 | // this isn't a very good test for verifying the prioritize cycle 352 | // was skipped, replace with a better test if one comes to mind 353 | }); 354 | }); 355 | }); 356 | -------------------------------------------------------------------------------- /src/connection-pool-party.js: -------------------------------------------------------------------------------- 1 | /* eslint no-param-reassign:0 */ 2 | // Unfortunately, we do a lot of parameter reassignment in this class, so we're 3 | // disabling this rule for the entire file. 4 | 5 | import { EventEmitter } from 'events'; 6 | import setDebug from 'debug'; 7 | import sql from 'mssql'; 8 | import promiseRetry from 'promise-retry'; 9 | import promiseReduce from 'promise-reduce'; 10 | import partial from 'lodash.partial'; 11 | import AggregateError from 'aggregate-error'; 12 | import uuidv4 from 'uuid/v4'; 13 | import validateConfig from './validate-config'; 14 | import addDefaultDsnProperties from './add-default-dsn-properties'; 15 | import defaultConnectionPoolFactory from './default-connection-pool-factory'; 16 | import addConnectionPoolProperties from './add-connection-pool-properties'; 17 | import raceWarmupStrategy from './race-warmup-strategy'; 18 | import PoolError from './pool-error'; 19 | import poolStats from './pool-stats'; 20 | import addDefaultStats from './add-default-stats'; 21 | import copyPoolStats from './copy-pool-stats'; 22 | import requestStreamPromise from './request-stream-promise'; 23 | import isStreamingEnabled from './is-streaming-enabled'; 24 | import wrapListeners from './wrap-listeners'; 25 | import requestMethodSuccess from './request-method-success'; 26 | import requestMethodFailure from './request-method-failure'; 27 | import poolPrioritySort from './pool-priority-sort'; 28 | 29 | const debug = setDebug('mssql-pool-party'); 30 | 31 | /** 32 | * Class representing a ConnectionPoolParty, which manages one or more ConnectionPool instance(s). 33 | * ConnectionPoolParty extends the mssql package to provide failover between ConnectionPools, 34 | * reconnets/retries, and basic health/statistics reporting. 35 | * @param {object} config - Configuration for ConnectionPoolParty 36 | * @param {number} [config.reconnects=0] - The number of times a request will be retried 37 | * against ALL pools. A heal operation is attempted before a reconnect. Total request 38 | * attempts is calculated using: pools * (1+reconnects) * (1+retries) 39 | * @param {number} [config.retries=0] - The number of times a request will be retried against 40 | * a single pool. Each pool is retried separately. Total request attempts is calculated using: 41 | * pools * (1+reconnects) * (1+retries) 42 | * @param {object} [config.dsn] - A single DSN, matches the configuration object expected 43 | * by the mssql package. Required if dsns and dsnProvider are not provided. 44 | * @param {array} [config.dsns] - An array of DSNs, each entry should match the configuraiton 45 | * object expected by the mssql package. Overrides config.dsn. Required if dsn and dsnProvider 46 | * are not provided. 47 | * @param {function} [config.dsnProvider] - A function returning a promise that resolves 48 | * with an array of dsn object(s). This option will override config.dsn and config.dsns. 49 | * Required if dsn and dsns are not provided. 50 | * @param {function} [config.connectionPoolFactory] - A function that receives the dsn objects 51 | * from the dnsProvider and returns a promise that resolves with *connected* instance(s) of 52 | * ConnectionPool. Use this option if you want to customize how mssql ConnectionPools are 53 | * instantiated and connected. 54 | * @param {object} [config.connectionPoolConfig] - An object containing any configuration 55 | * you want to attach to the config provided when creating an mssql ConnectionPool. This is 56 | * useful if you don't want to create a custom dsnProvider or connectionPoolFactory to modify 57 | * the configuration used to create ConnectionPools. Just keep in mind that any config set here 58 | * will override the config set in the dsnProvider. Check [node-mssql README.md](https://github.com/tediousjs/node-mssql/blob/master/README.md#general-same-for-all-drivers) 59 | * for more information. 60 | * @param {object} [config.connectionPoolConfig.options] - An object containing any configuration 61 | * you want to pass all the way to driver used by node-mssql, e.g. appName, encrypt, etc. 62 | * Check [node-mssql README.md](https://github.com/tediousjs/node-mssql/blob/master/README.md#tedious) 63 | * for more information. 64 | * @param {object} [config.connectionPoolConfig.pool] - An object containing any configuration 65 | * you want to pass to the pool implementation internal to node-mssql, e.g. max, min, 66 | * idleTimeout, etc. Check [node-mssql README.md](https://github.com/tediousjs/node-mssql/blob/master/README.md#general-same-for-all-drivers) 67 | * for more information. 68 | * @param {boolean} [config.prioritizePools] - A flag to enable pool prioritization behavior. 69 | * If you enable this behavior, your dsns must have a numeric priority property. 70 | * The lower the number, the higher the priority of the dsn, starting at 0. 71 | * At a specified interval, the pools collection will be examined to see if the pools 72 | * are no longer indexed in order of priority. If this is the case, the pools will be 73 | * healed (if applicable) and re-ordered in terms of their priority. This is a useful 74 | * behavior if you want to fail back to a "primary" dsn after it becomes healthy again. 75 | * @param {number} [config.prioritizeInterval=30000] - The interval in milliseconds 76 | * to run the pool prioritization check. Setting a value below 10000 is not advised, 77 | * as the pool prioritization check can take significant resources if a pool heal is required. 78 | * @param {function} [cb] - Optional callback interface, providing this automatically calls 79 | * warmup. It is preferable to use the Promise-based interface and call warmup explicitly. 80 | * @class 81 | * @extends EventEmitter 82 | * @memberof module:connection-pool-party 83 | */ 84 | export default class ConnectionPoolParty extends EventEmitter { 85 | constructor(config, cb) { 86 | super(); 87 | debug('Creating ConnectionPoolParty instance'); 88 | validateConfig(config); 89 | this.configDefaults = { 90 | reconnects: 0, 91 | retries: 0, 92 | prioritizePools: false, 93 | prioritizeInterval: 30000, 94 | }; 95 | 96 | this.config = { 97 | ...this.configDefaults, 98 | ...config, 99 | }; 100 | this.requestDefaults = { 101 | reconnects: this.config.reconnects, 102 | retries: this.config.retries, 103 | }; 104 | this.warmedUp = false; 105 | this.reconnectCount = 0; 106 | this.pools = []; 107 | // use the dsnProvider from config, or just emulate a dsnProvider 108 | // using the dsn(s) provided 109 | this.dsnProvider = this.config.dsnProvider || 110 | (() => Promise.resolve(this.config.dsns || [this.config.dsn])); 111 | this.connectionPoolFactory = this.config.connectionPoolFactory || 112 | defaultConnectionPoolFactory; 113 | // we need a way to set mssql ConnectionPool config properties without 114 | // having to specify a custom dsnProvider or connecitonPoolFactory. this 115 | // gives us that. 116 | this.connectionPoolConfig = this.config.connectionPoolConfig || {}; 117 | // enable driver encryption by default 118 | // https://github.com/tediousjs/tedious/blob/85d3e20cad481492b6f6b9cb7e9fd8feee6d599e/src/connection.js#L358 119 | this.connectionPoolConfig.options = { 120 | encrypt: true, 121 | ...this.connectionPoolConfig.options, 122 | }; 123 | this.warmupStrategy = this.config.warmupStrategy || raceWarmupStrategy; 124 | this._warmupPromise = null; 125 | this._healingPromise = null; 126 | this._prioritizePromise = null; 127 | this._prioritizeTimer = null; 128 | // we don't want an 'Uncaught, unspecified "error" event.' exception 129 | // so we have a dummy listener here. 130 | this.on('error', () => {}); 131 | if (typeof cb === 'function') { 132 | this.warmup(cb); 133 | } 134 | } 135 | 136 | /** 137 | * Retrieve the dsn(s) from the dsnProvider, create and connect the ConnectionPool 138 | * instance(s) using the connectionPoolFactory. Returns a promise. Can be called 139 | * to explicitly warmup database connections. Called implicitly when submitting 140 | * any requests. After a successful warmup, subsequent calls will not warmup again. 141 | * @param {function} [cb] - An optional callback interface. It is preferable to use the 142 | * Promise-based interface. 143 | * @return {Promise} A promise indicating that a warmup was successful. This promise 144 | * cannot reject, but errors during warmup will result in the cached warmup promise 145 | * being removed, which will allow warmup to be re-attempted. 146 | * @memberof module:connection-pool-party.ConnectionPoolParty 147 | * @method #warmup 148 | */ 149 | warmup = (cb) => { 150 | if (!this._warmupPromise) { 151 | debug('warmup called'); 152 | } 153 | // only run one warmup at a time for each instance of ConnectionPoolParty 154 | this._warmupPromise = this._warmupPromise || this.dsnProvider() 155 | .then(addDefaultDsnProperties) 156 | .then(addConnectionPoolProperties(this.connectionPoolConfig)) 157 | .then((dsns) => { 158 | debug('retrieved dsns \n%O', dsns || 'NONE'); 159 | // make sure we empty the pools (they should already be empty) 160 | this.pools = []; 161 | // the warmup strategy decides how we want to wait for the connections 162 | // to be created. by default, our strategy is to continue after 163 | // we get at least one succesful connection (and it will be placed) 164 | // as the initial primary. Even though we continue after the first 165 | // successful connection, the rest of the pool(s) will be added (if 166 | // there are anymore). 167 | return this.warmupStrategy( 168 | dsns, 169 | this.connectionPoolFactory, 170 | (pool) => { 171 | debug('pool created for dsn %s (%s), at index %d\n%O', pool.dsn.id, pool.dsn.server, this.pools.length, pool); 172 | this.pools.push(addDefaultStats(pool)); 173 | }, 174 | this.emit.bind(this, 'error'), 175 | ); 176 | }) 177 | .then(() => { 178 | // if we've gotten here, then at least one pool succesfully connected 179 | this.warmedUp = true; 180 | // we only start prioritizing after a successful warmup 181 | if (this.config.prioritizePools) { 182 | this._startPrioritizingPools(); 183 | } 184 | }) 185 | .catch((err) => { 186 | debug('failed to retrieve dsns! reseting warmup promise so that another attempt can be made'); 187 | debug(err); 188 | this.emit('error', err); 189 | // reset the warmup promise so it can be called again 190 | this._warmupPromise = null; 191 | }); 192 | if (typeof cb === 'function') { 193 | return this._warmupPromise.then(cb); 194 | } 195 | return this._warmupPromise; 196 | } 197 | 198 | /** 199 | * Retrieve a new Request instance. This is the same Request provided by the mssql 200 | * package, but it's specially extended to interact with ConnectionPoolParty. 201 | * @return {mssql.Request} An extended instance of mssql.Request. 202 | * @memberof module:connection-pool-party.ConnectionPoolParty 203 | * @method #request 204 | */ 205 | request = (options = {}) => { 206 | const optionsWithDefaults = { 207 | ...this.requestDefaults, 208 | ...options, 209 | }; 210 | const request = new sql.Request(); 211 | // We need to explicitly set stream to true if it's in the config until 212 | // this bug is fixed https://github.com/tediousjs/node-mssql/issues/705 213 | request.stream = !!( 214 | this.config.connectionPoolConfig && 215 | this.config.connectionPoolConfig.stream 216 | ); 217 | // This helps identify individual requests in the debug output 218 | if (debug.enabled) { 219 | request.id = uuidv4(); 220 | } 221 | return this._wrapRequest(optionsWithDefaults, request); 222 | } 223 | 224 | /** 225 | * Close all pools associated with this instance of ConnectionPoolParty 226 | * @param {function} [cb] - An optional callback interface. It is preferable to use the 227 | * Promise-based interface. 228 | * @return {Promise} A Promise that resolves when all pools are closed. Will also 229 | * resolve if there is an error encountered while closing the pools. 230 | * @memberof module:connection-pool-party.ConnectionPoolParty 231 | * @method #close 232 | */ 233 | close = (cb) => Promise.all( 234 | this.pools.map((pool) => { 235 | debug('closing pool %s', pool.dsn.id); 236 | return pool.connection.close(); 237 | }), 238 | ) 239 | .then(() => { 240 | debug('all pools closed'); 241 | this.pools = []; 242 | }) 243 | .catch((err) => { 244 | debug('one or more pools failed to close!\n%O', err); 245 | this.pools = []; 246 | this.emit('error', err); 247 | }) 248 | .then(() => { 249 | if (this._prioritizeTimer) { 250 | clearInterval(this._prioritizeTimer); 251 | this._prioritizeTimer = null; 252 | debug('prioritize timer stopped'); 253 | } 254 | if (typeof cb === 'function') { 255 | cb(); 256 | } 257 | }) 258 | 259 | /** 260 | * Retrieve health and statistics for this ConnectionPoolParty and its associated 261 | * pools. 262 | * @return {Object} An object containing a bunch of health/stats data for this instance 263 | * of ConnectionPoolParty and its associated pools. 264 | * @memberof module:connection-pool-party.ConnectionPoolParty 265 | * @method #stats 266 | */ 267 | stats = () => ({ 268 | pools: this.pools.map(poolStats), 269 | healing: !!this._healingPromise, // the promise only exists during healing 270 | warmedUp: this.warmedUp, 271 | reconnects: this.config.reconnects, 272 | reconnectCount: this.reconnectCount, 273 | retries: this.config.retries, 274 | // retryCount is tracked on each pool 275 | }); 276 | 277 | _tryRequest = (options, request, originalMethod, args, attempts, pool, poolIndex) => { 278 | // we already completed the request with a previous pool, no need to continue 279 | if (attempts.success) { 280 | return attempts; 281 | } 282 | // node-mssql has flagged the pool as unhealthy 283 | // the pool needs to go through a reconnect/heal before it's used 284 | if (!pool.connection.healthy) { 285 | debug('request (%s) failed for pool %s because pool.connection is flagged unhealthy', request.id, pool.dsn.id); 286 | attempts.poolIndex = poolIndex; 287 | attempts.tryNumber = 0; 288 | attempts.attemptNumber += 1; 289 | attempts.unhealthyPools.push(pool); 290 | attempts.errors.push(new PoolError(pool, `Request ${request.id} failed because connection is unhealthy.`)); 291 | return attempts; 292 | } 293 | return promiseRetry( 294 | { retries: options.retries }, 295 | (retry, tryNumber) => { 296 | // run the request using the pool 297 | request.parent = pool.connection; 298 | // we want to record each time we rely on a retry in a pool's stats 299 | if (tryNumber > 1) { 300 | pool.retryCount += 1; 301 | } 302 | attempts.poolIndex = poolIndex; 303 | attempts.tryNumber = tryNumber; 304 | attempts.attemptNumber += 1; 305 | // if streaming is enabled, we need to make the stream events 306 | // promise-friendly, so we can continue to use the same logic 307 | // downstream to handle retries and such 308 | const originalMethodPromise = isStreamingEnabled(pool, request) 309 | ? requestStreamPromise(request, originalMethod, attempts) 310 | : originalMethod; 311 | return originalMethodPromise.apply(request, args) 312 | .catch((err) => { 313 | // if there is a failure, check to see if the request can be retried 314 | if (this._isErrorRetryable(err)) { 315 | return retry(err); 316 | } 317 | throw err; 318 | }); 319 | }, 320 | ) 321 | .then( 322 | (result) => { 323 | // the request succeeded, just record and return the results 324 | attempts.success = result; 325 | return attempts; 326 | }, 327 | (err) => { 328 | // the request failed, record the error and check to see 329 | // if the pool is unhealthy 330 | debug('request (%s) failed for pool %s\n%O', request.id, pool.dsn.id, err); 331 | attempts.errors.push(new PoolError(pool, err)); 332 | if (this._isPoolUnhealthy(pool, err)) { 333 | attempts.unhealthyPools.push(pool); 334 | } 335 | return attempts; 336 | }, 337 | ); 338 | } 339 | 340 | _wrapRequest = (options, request) => { 341 | // methods on the request that initiate communication with the sql 342 | // server are wrapped to support failovers, retries, etc. 343 | ['batch', 'bulk', 'execute', 'query'].forEach((func) => { 344 | request[func] = this._wrapRequestMethod(options, request, func); 345 | }); 346 | // methods that deal with event listeners need to be wrapped 347 | // to support the streaming interface 348 | ['on', 'removeListener'].forEach((func) => { 349 | request[func] = wrapListeners(request, func); 350 | }); 351 | return request; 352 | } 353 | 354 | _wrapRequestMethod = (options, request, method) => { 355 | const originalMethod = request[method]; 356 | return (...args) => { 357 | // need to support the same optional callback interface provided by mssql package 358 | const cb = args[args.length - 1]; 359 | if (typeof cb === 'function') { 360 | // we don't want to pass the cb to the request method because it 361 | // returns different values, so we pop it here and call it at the end 362 | args.pop(); 363 | } 364 | // we use attempts to track the state of a request over the many possible 365 | // retries and reconnects that may take place. it's a closure accessed 366 | // and mutated across multiple links in a promise chain, which isn't great. 367 | // look into refactoring this later if possible. 368 | const attempts = { 369 | success: null, 370 | errors: [], 371 | unhealthyPools: [], 372 | attemptNumber: 0, 373 | anyPoolsHealed: false, 374 | }; 375 | return promiseRetry( 376 | { retries: options.reconnects }, 377 | // make sure we're warmed up 378 | // if we're already warmed up, this will just immediately resolve 379 | (retry, connectNumber) => this.warmup() 380 | .then(() => { 381 | attempts.connectNumber = connectNumber; 382 | // if there are errors from the last attempt, we emit them and 383 | // clear the collection so that each run has a clean slate 384 | if (attempts.errors.length > 0) { 385 | const allErrors = new AggregateError(attempts.errors); 386 | this.emit('error', allErrors); 387 | attempts.errors = []; 388 | } 389 | if (this.pools.length === 0) { 390 | // it's possible for a warmup to fail and no pools to be created, if so 391 | // we stop here and hope a retry succeeds 392 | attempts.errors.push(new Error('No pools detected, warmup may have failed.')); 393 | return attempts; 394 | } 395 | // if connectNumber is greater than one, then there was a reconnect 396 | // and we want to track that on the ConnectionPoolParty instance 397 | if (connectNumber > 1) { 398 | this.reconnectCount += 1; 399 | } 400 | // if all the pools are unhealthy from the last attempt, we 401 | // skip trying to re-run the request and go straight to 402 | // another heal attempt. 403 | // otherwise, we just clear out the unhealthy pools and let 404 | // _tryRequest re-identify them. 405 | if (!attempts.anyPoolsHealed && 406 | attempts.unhealthyPools.length === this.pools.length 407 | ) { 408 | debug('none of the pools are healthy, skipping _tryRequest, attempting a heal'); 409 | return attempts; 410 | } 411 | attempts.unhealthyPools = []; 412 | attempts.anyPoolsHealed = false; 413 | debug('info for request %s (%s)\nargs: %O\nattempts: %O', method, request.id, args.join(', '), attempts); 414 | // debug(`attempt ${attempts.attemptNumber} for request ${request.id} (0 is first)`); 415 | // attempt request using each pool sequentially, skips others after success 416 | // clone array to avoid mutation during iteration 417 | return Promise.resolve([...this.pools]) 418 | .then(promiseReduce( 419 | // try to make the request using a pool 420 | partial(this._tryRequest, options, request, originalMethod, args), 421 | // collect the results using the attempts object 422 | attempts, 423 | )); 424 | }) 425 | // after making attempts on the pools, check the results to see 426 | // if any succeeded 427 | .then(() => { 428 | if (attempts.success) { 429 | // if one of the failover pools succeeded, promote it to primary 430 | if (attempts.poolIndex > 0) { 431 | this._promotePool(attempts.poolIndex); 432 | } 433 | return attempts; 434 | } 435 | // only attempt to heal pools if the request was not successful. 436 | // this should reduce unnecessary connections to non-primary pools. 437 | // returns a bool indicating if a heal attempt was made against any pool. 438 | return this._healPools(attempts.unhealthyPools) 439 | .then((anyPoolsHealed) => { 440 | attempts.anyPoolsHealed = anyPoolsHealed; 441 | // if the request wasn't successful, we retry. 442 | // if we have exceeded the max number of reconnects, this will 443 | // throw instead of retrying 444 | const allErrors = new AggregateError(attempts.errors); 445 | return retry(allErrors); 446 | }); 447 | }), 448 | ) 449 | .then( 450 | // all done, process the results and return them using the correct 451 | // interface (promise, stream, or callback) 452 | requestMethodSuccess(request, attempts, cb), 453 | requestMethodFailure(request, attempts, cb), 454 | ); 455 | }; 456 | } 457 | 458 | _promotePool = (poolIndex) => { 459 | // if pools are being healed or prioritized, we can't mess with this.pools since 460 | // it's mutated during those operations. another successful request will 461 | // have to promote the pool 462 | if (this._healingPromise) { 463 | debug('_promotePool called during heal, skipping promotion'); 464 | return; 465 | } 466 | if (this._prioritizePromise) { 467 | debug('_promotePool called during prioritize, skipping promotion'); 468 | return; 469 | } 470 | // track some stats 471 | this.pools[poolIndex].lastPromotionAt = Date.now(); 472 | this.pools[poolIndex].promotionCount += 1; 473 | // moves the pool at poolIndex to the start of the pools array 474 | this.pools = [...this.pools.splice(poolIndex, 1), ...this.pools]; 475 | } 476 | 477 | // TODO, need to identify which errors are retryable 478 | _isErrorRetryable = (/* err */) => true 479 | 480 | // TODO, need to identify which pool states and errors indicate an unhealthy pool 481 | _isPoolUnhealthy = (/* pool, err */) => true 482 | 483 | _healPools = (unhealthyPools) => { 484 | // if we don't have any unhealthy pools, just return 485 | if (unhealthyPools.length === 0) { 486 | debug('_healPools called with no unhealthy pools'); 487 | return Promise.resolve(false); 488 | } 489 | // get any updated dsn info from the provider 490 | this._healingPromise = this._healingPromise || (Promise.resolve() 491 | .then(() => debug('healing started, retrieving new dsns from provider')) 492 | .then(() => this.dsnProvider()) 493 | .then(addDefaultDsnProperties) 494 | .then(addConnectionPoolProperties(this.connectionPoolConfig)) 495 | .catch((err) => { 496 | debug(`failed to retrieve updated dsns, using existing dsns to 497 | create new connections`); 498 | this.emit('error', err); 499 | return this.pools.map((pool) => pool.dsn); 500 | }) 501 | .then((dsns) => Promise.all( 502 | // take note, this._healPool never rejects, but it can resolve with errors 503 | unhealthyPools.map((unhealthyPool) => this._healPool(dsns, unhealthyPool)), 504 | )) 505 | .then((results) => { 506 | let anyPoolsHealed = false; 507 | results.forEach((result) => { 508 | if (result instanceof Error) { 509 | this.emit('error', result); 510 | return; 511 | } 512 | // are any of the results truthy and not an error? 513 | anyPoolsHealed = anyPoolsHealed || !!result; 514 | }); 515 | this._healingPromise = null; 516 | debug(`healing complete, any pools healed? ${anyPoolsHealed}`); 517 | return anyPoolsHealed; 518 | })); 519 | return this._healingPromise; 520 | } 521 | 522 | _healPool = (dsns, unhealthyPool) => { 523 | const unhealthyPoolIndex = this.pools.findIndex((pool) => pool.dsn.id === unhealthyPool.dsn.id); 524 | if (unhealthyPoolIndex === -1) { 525 | // the unhealthy pool has already been removed from the pools collections, 526 | // so nothing needs to be done 527 | return Promise.resolve(new Error(` 528 | Could not find unhealthy pool with id ${unhealthyPool.dsn.id} in the pools 529 | collection, so we cannot heal this pool. If this is happening, there is probably 530 | a bug somewhere... 531 | `)); 532 | } 533 | const updatedDsn = dsns.find((dsn) => dsn.id === unhealthyPool.dsn.id); 534 | if (!updatedDsn) { 535 | // remove unhealthy pool, it cannot be healed 536 | this.pools.splice(unhealthyPoolIndex, 1); 537 | unhealthyPool.connection.close(); 538 | return Promise.resolve(new Error(` 539 | Attempted to heal pool but could not find matching DSN. 540 | The dsnProvider is no longer providing a DSN with id ${unhealthyPool.dsn.id}. 541 | The pool assigned to this unhealthy DSN will be closed, but one will not 542 | be created to take its place. Make sure your dsnProvider always returns 543 | dsns with the same ids used during initial warmup. 544 | `)); 545 | } 546 | return Promise.resolve() 547 | .then(() => debug(`healing pool ${unhealthyPool.dsn.id}`)) 548 | .then(() => this.connectionPoolFactory(updatedDsn)) 549 | .then( 550 | (pool) => { 551 | if (pool.error) { 552 | // some connection pool factories may opt to return an error instead 553 | // of rejecting the promise. the existence of an error indicates that 554 | // the pool did not heal successfully 555 | return pool.error; 556 | } 557 | // need to transfer stats from the old unhealthy pool to the new one (mutates) 558 | copyPoolStats(unhealthyPool, pool); 559 | pool.lastHealAt = Date.now(); 560 | pool.healCount += 1; 561 | this.pools.splice(unhealthyPoolIndex, 1, pool); 562 | debug(`pool ${unhealthyPool.dsn.id} healed`); 563 | unhealthyPool.connection.close(); 564 | return true; 565 | }, 566 | (err) => err, 567 | ); 568 | } 569 | 570 | _prioritizePools = () => { 571 | if (!this.warmedUp) { 572 | debug('_prioritizePools called before warmup completed. this should not happen.'); 573 | return; 574 | } 575 | if (this._healingPromise) { 576 | debug('_prioritizePools called during heal, skipping this run'); 577 | return; 578 | } 579 | if (this.pools.length === 0) { 580 | debug('_prioritizePools called when no pools exist, this should not happen'); 581 | return; 582 | } 583 | if (this.pools.length === 1) { 584 | debug('_prioritizePools called when only one pool exists, skipping this run'); 585 | return; 586 | } 587 | if (this._prioritizePromise) { 588 | debug('_prioritizePools called again while already in progress'); 589 | } else { 590 | debug('_prioritizePools called'); 591 | } 592 | const firstPoolPriority = this.pools[0].dsn.priority; 593 | if (firstPoolPriority === undefined || firstPoolPriority === 0) { 594 | debug('first pool has top priority, no need to prioritize'); 595 | return; 596 | } 597 | const higherPriorityPools = this.pools.filter( 598 | (pool) => pool.dsn.priority < firstPoolPriority, 599 | ); 600 | if (higherPriorityPools.length === 0) { 601 | debug('unexpected priority config on DSNs, unable to prioritize'); 602 | return; 603 | } 604 | const unhealthyPriorityPools = higherPriorityPools.filter( 605 | (pool) => !pool.connection.healthy || 606 | ( 607 | !pool.connection.connecting && 608 | !pool.connection.connected 609 | ), 610 | ); 611 | this._prioritizePromise = this._prioritizePromise || Promise.resolve(unhealthyPriorityPools) 612 | .then((unhealthyPools) => { 613 | // If all the pools of a higher priority than index 0 are healthy, we can 614 | // skip the heal. If there are any unhealthy pools, 615 | // we need to heal them before sorting. 616 | if (unhealthyPools.length === 0) { 617 | debug('no unhealthy pools detected during prioritization'); 618 | return true; 619 | } 620 | debug(`healing ${unhealthyPools.length} pools during prioritization`); 621 | return this._healPools(unhealthyPools); 622 | }) 623 | .then((anyHealthyPools) => { 624 | if (!anyHealthyPools) { 625 | debug('none of the pools eligible for prioritization are healthy, unable to prioritize'); 626 | return; 627 | } 628 | this.pools.sort(poolPrioritySort); 629 | debug('prioritized pools'); 630 | }) 631 | .then(() => { 632 | this._prioritizePromise = null; 633 | }) 634 | .catch((err) => { 635 | debug('unexpected error during _prioritizePools'); 636 | debug(err); 637 | this._prioritizePromise = null; 638 | }); 639 | } 640 | 641 | _startPrioritizingPools = () => { 642 | if (this._prioritizeTimer) { 643 | // Prioritizing has already begun 644 | return; 645 | } 646 | debug(`_startPrioritizingPools called with interval ${this.config.prioritizeInterval}`); 647 | this._prioritizeTimer = setInterval(() => { 648 | this._prioritizePools(); 649 | }, this.config.prioritizeInterval); 650 | } 651 | } 652 | --------------------------------------------------------------------------------