├── .github └── workflows │ └── publish_latest_version_to_gh.yml ├── .gitignore ├── .npmrc ├── .travis.yml ├── README.md ├── assets └── event-flowchart.dot ├── db-session.js ├── lib ├── domain.js ├── session-connpair.js └── tx-session-connpair.js ├── package.json ├── test ├── basic-api-errors-test.js ├── basic-atomic-concurrency-test.js ├── basic-atomic-error-test.js ├── basic-atomic-from-session-test.js ├── basic-interleaved-test.js ├── basic-rollback-test.js ├── basic-session-concurrency-test.js ├── basic-session-error-test.js ├── basic-transaction-concurrency-test.js ├── basic-transaction-error-test.js ├── integrate-pg-pool-sequence-test.js ├── integrate-pummel-leak-test.js └── integrate-server-test.js └── utils └── delay.js /.github/workflows/publish_latest_version_to_gh.yml: -------------------------------------------------------------------------------- 1 | name: Check version and publish package 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | permissions: 8 | contents: read 9 | packages: write 10 | 11 | jobs: 12 | compare_versions: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Compare current repo version with all published versions 18 | id: compare_versions 19 | continue-on-error: false 20 | run: | 21 | package_name="$(npm run env | grep npm_package_name | cut -d '=' -f 2)" 22 | repo_version="$(npm run env | grep npm_package_version | cut -d '=' -f 2)" 23 | echo "Repo version is: $repo_version " 24 | 25 | npm config set //npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }} 26 | npm config set @npm:registry=https://npm.pkg.github.com/ 27 | 28 | versions_list="$(npm view $package_name versions | tr -d '[,]')" 29 | echo "Published package versions: $versions_list " 30 | 31 | [[ "${versions_list}" =~ "'${repo_version}'" ]] && echo "::set-output name=should_publish::false" || echo "::set-output name=should_publish::true" 32 | 33 | shell: bash 34 | 35 | - name: Publish package 36 | if: steps.compare_versions.outputs.should_publish == 'true' 37 | run: npm publish 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules 3 | package-lock.json -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @npm:registry=https://npm.pkg.github.com/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | env: 4 | - TAP_TIMEOUT=60 5 | node_js: 6 | - "8.*" 7 | - "9.*" 8 | addons: 9 | postgresql: "9.4" 10 | services: 11 | - postgresql 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pg-db-session 2 | 3 | Abuse domains to get a form of continuation local storage. Associate all events 4 | originating from a single domain to a single database session, which manages 5 | maximum concurrency, transactions, and operation ordering for consumers of the 6 | database connection. 7 | 8 | ```javascript 9 | const db = require('pg-db-session') 10 | const domain = require('domain') 11 | const http = require('http') 12 | const pg = require('pg') 13 | 14 | http.createServer((req, res) => { 15 | const d = domain.create() 16 | d.add(req) 17 | d.add(res) 18 | 19 | db.install(d, () => { 20 | return new Promise((resolve, reject) => { 21 | pg.connect(CONFIG, (err, connection, release) => { 22 | err ? reject(err) : resolve({connection, release}) 23 | }) 24 | }) 25 | }, {maxConcurrency: 2}) 26 | 27 | d.run(() => { 28 | // handle some code. 29 | someOperation() 30 | someAtomic() 31 | }) 32 | }) 33 | 34 | const someOperation = db.transaction(function operation () { 35 | // this code will always run inside an operation 36 | return db.getConnection().then(pair => { 37 | pair.connection.query('DELETE FROM all', err => pair.release(err)) 38 | }) 39 | }) 40 | 41 | const someAtomic = db.atomic(function atom () { 42 | // this code will always be run inside an operation together, 43 | // with savepoints. 44 | }) 45 | ``` 46 | 47 | Database sessions are active whenever their associated domain is active. This means 48 | that a domain can be associated with a request, and all requests for a connection 49 | will be managed by the session associated with that domain. 50 | 51 | Database sessions manage access to the lower-level postgres connection pool. 52 | This lets users specify maximum concurrency for a given session — for instance, 53 | retaining a pool of 20 connections, but only allotting a maximum of 4 54 | concurrent connections per incoming HTTP request. 55 | 56 | Sessions also manage *transaction* status — functions may be decorated with 57 | "transaction" or "atomic" wrappers, and the active session will automatically 58 | create a transactional sub-session for the execution of those functions and any 59 | subsequent events they spawn. Any requests for a connection will be handled by 60 | the subsession. The transaction held by the subsession will be committed or 61 | rolled back based on the fulfillment status of the promise returned by the 62 | wrapped function. Transactional sessions hold a single connection, releasing it 63 | to connection requests sequentially — this naturally reduces the connection 64 | concurrency to one. 65 | 66 | Atomics, like transactions, hold a single connection, delegating sequentially. 67 | They're useful for grouping a set of operations atomically within a 68 | transaction. Atomics are wrapped in a `SAVEPOINT` — releasing the savepoint if 69 | the promise returned by the wrapped function is fulfilled, and rolling back to 70 | it if the promise is rejected. Atomics may be nested. 71 | 72 | ## API 73 | 74 | #### `db.install(d:Domain, getConnection:ConnPairFn, opts:Options)` 75 | 76 | Install a database `Session` on the domain `d`. 77 | 78 | ##### `Options` 79 | 80 | Sessions accept the following options: 81 | 82 | assets/event-flowchart.dot 83 | 84 | * `maxConcurrency`: An integer specifying the maximum number of connections a 85 | given session will make at a time. `0` is treated as `Infinity`. Defaults to 86 | `Infinity`. *Note:* this number is implicitly bound by the size of the `pg` 87 | connection pool. For example, even if the limit is set at `200`, if `pg`'s 88 | pool size is limited to `10`, the upper limit will effectively be `10`. 89 | * `onSessionIdle()`: A function that is called whenever all requests for 90 | connections have been satisfied. Note that this may happen while connections 91 | are still open. 92 | * `onConnectionRequest(baton)`: A function accepting a baton object that is 93 | called when a request for a connection is made. 94 | * `onConnectionStart(baton)`: A function acccepting a baton object that is 95 | called when a request for a connection is fulfilled. The baton will be the 96 | same object that was passed to a previous call to `onConnectionRequest`, 97 | suitable for associating timing information. 98 | * `onConnectionFinish(baton, err)`: A function accepting a baton object and 99 | an optional `err` parameter that will be called when a connection is released 100 | back to the session. 101 | * `onTransactionRequest(baton, operation, args)`: A function accepting a baton, 102 | function, and array of arguments, representing the request for a transaction 103 | session. Called coincident with `onConnectionRequest`. 104 | * `onTransactionStart(baton, operation, args)`: A function accepting a baton, 105 | function, and array of arguments, representing the fulfillment of a request 106 | for a transaction session. Called before `BEGIN`, coincident with 107 | `onConnectionStart`. 108 | * `onTransactionFinish(baton, operation, args, PromiseInspection)`: 109 | A function accepting a baton, function, array of arguments, and a 110 | [`PromiseInspection`][bluebird-inspection] representing the state of the 111 | transaction. Called coincident with `onConnectionFinish`. 112 | * `onTransactionConnectionRequest(baton)`: A function accepting a baton, 113 | representing the request for a connection within a transaction session. 114 | * `onTransactionConnectionStart(baton)`: A function accepting a baton, 115 | representing the fulfillment of a request for a connection within a 116 | transaction session. 117 | * `onTransactionConnectionFinish(baton, err)`: A function accepting a baton 118 | and an optional `err` argument, representing the completion of a transaction 119 | connection within a transaction session. 120 | * `onAtomicRequest(baton, operation, args)`: A function accepting a baton, 121 | function, and array of arguments, representing the request for an atomic 122 | session. 123 | * `onAtomicStart(baton, operation, args)`: A function accepting a baton, 124 | function, and array of arguments, representing the fulfillment of a request 125 | for an atomic session. 126 | * `onAtomicFinish(baton, operation, args, PromiseInspection)`: 127 | A function accepting a baton, function, array of arguments, and a 128 | [`PromiseInspection`][bluebird-inspection] representing the state of the 129 | atomic transaction. 130 | * `onSubsessionStart(parentSession, childSession)`: Useful for copying 131 | information down from parent sessions to child sessions. 132 | * `onSubsessionFinish(parentSession, childSession)`: Useful for cleaning up 133 | information from child sessions. 134 | 135 | All functions will default to `noop` if not provided. 136 | 137 | ##### `ConnPairFn := Function → Promise({connection, release})` 138 | 139 | A function that returns a `Promise` for an object with `connection` and `release` 140 | properties, corresponding to the `client` and `done` parameters handed back by 141 | [node-postgres][]. 142 | 143 | Usually, this will look something like the following: 144 | 145 | ```javascript 146 | function getConnection () { 147 | return new Promise((resolve, reject) => { 148 | pg.connect(CONNECTION_OPTIONS, (err, client, done) => { 149 | err ? reject(err) : resolve({ 150 | connection: client, 151 | release: done 152 | }) 153 | }) 154 | }) 155 | } 156 | ``` 157 | 158 | #### `db.getConnection() → Promise({connection, release})` 159 | 160 | Request a connection pair. `release` should be called when the connection is no 161 | longer necessary. 162 | 163 | #### `db.transaction(Function → Promise) → Function` 164 | 165 | Wrap a function as requiring a transaction. 166 | 167 | ```javascript 168 | const updateUser = db.atomic(function _updateUser(userId, name) { 169 | const getPair = db.getConnection() 170 | const queryDB = getPair.get('connection').then(conn => { 171 | return Promise.promisify(conn.query, {context: conn})( 172 | 'UPDATE users SET name = $1 WHERE id = $2', [name, userId] 173 | ) 174 | }) 175 | const releaseConn = queryDB.return(getPair.get('release')) 176 | .then(release => release()) 177 | return releaseConn.return(queryDB) 178 | }) 179 | 180 | // from inside an active session: 181 | updateUser(1313, 'gary').then(results => { 182 | 183 | }) 184 | ``` 185 | 186 | #### `db.atomic(Function → Promise) → Function` 187 | 188 | Wrap a function as an atomic. This groups all pending connection requests made by 189 | the function and all subsequent events the function calls together, such that they 190 | are resolved before any other pending requests. This is useful for operations that 191 | stretch multiple queries, for example if you had to: 192 | 193 | 1. Fetch some data, 194 | 2. then insert a row in one table, 195 | 3. and then insert a row in another table, 196 | 197 | One might write that as an atomic function so that the three operations are grouped 198 | despite being spaced out temporally. 199 | 200 | [node-postgres]: https://github.com/brianc/node-postgres 201 | [bluebird-inspection]: http://bluebirdjs.com/docs/api/promiseinspection.html 202 | -------------------------------------------------------------------------------- /assets/event-flowchart.dot: -------------------------------------------------------------------------------- 1 | digraph metrics { 2 | A [label="(beginning state)"]; 3 | A -> onConnectionRequest; 4 | onConnectionRequest -> onConnectionStart; 5 | onConnectionStart -> onConnectionFinish; 6 | onConnectionFinish -> onSessionIdle; 7 | onConnectionFinish -> onConnectionStart; 8 | onSessionIdle -> onConnectionRequest; 9 | 10 | onConnectionRequest -> onTransactionRequest; 11 | onTransactionRequest -> onConnectionStart; 12 | onConnectionStart -> onTransactionStart; 13 | onTransactionStart -> onTransactionFinish; 14 | onTransactionFinish -> onConnectionFinish; 15 | 16 | onTransactionStart -> onTransactionConnectionRequest; 17 | onTransactionConnectionRequest -> onTransactionConnectionStart; 18 | onTransactionConnectionRequest -> onAtomicRequest; 19 | onAtomicRequest -> onTransactionConnectionStart; 20 | onTransactionConnectionStart -> onTransactionConnectionFinish; 21 | onTransactionConnectionFinish -> onTransactionConnectionStart; 22 | onTransactionConnectionFinish -> onTransactionFinish; 23 | 24 | onTransactionConnectionStart -> onAtomicStart; 25 | onAtomicFinish -> onTransactionConnectionFinish; 26 | onAtomicStart -> onAtomicFinish; 27 | } 28 | 29 | -------------------------------------------------------------------------------- /db-session.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const DOMAIN_TO_SESSION = new WeakMap() 4 | 5 | const TxSessionConnectionPair = require('./lib/tx-session-connpair.js') 6 | const SessionConnectionPair = require('./lib/session-connpair.js') 7 | const domain = require('./lib/domain') 8 | 9 | class NoSessionAvailable extends Error { 10 | constructor () { 11 | super('No session available') 12 | Error.captureStackTrace(this, NoSessionAvailable) 13 | } 14 | } 15 | 16 | function noop () { 17 | } 18 | 19 | const api = module.exports = { 20 | install (domain, getConnection, opts) { 21 | opts = Object.assign({ 22 | maxConcurrency: Infinity, 23 | onSubsessionStart: noop, 24 | onSubsessionFinish: noop, 25 | onSessionIdle: noop, 26 | onConnectionRequest: noop, 27 | onConnectionStart: noop, 28 | onConnectionFinish: noop, 29 | onTransactionRequest: noop, 30 | onTransactionStart: noop, 31 | onTransactionFinish: noop, 32 | onTransactionConnectionRequest: noop, 33 | onTransactionConnectionStart: noop, 34 | onTransactionConnectionFinish: noop, 35 | onAtomicRequest: noop, 36 | onAtomicStart: noop, 37 | onAtomicFinish: noop 38 | }, opts || {}) 39 | DOMAIN_TO_SESSION.set(domain, new Session( 40 | getConnection, 41 | opts 42 | )) 43 | }, 44 | 45 | atomic (operation) { 46 | return async function atomic$operation () { 47 | const args = [].slice.call(arguments) 48 | return await api.session.atomic(operation.bind(this), args) 49 | } 50 | }, 51 | 52 | transaction (operation) { 53 | return async function transaction$operation () { 54 | const args = [].slice.call(arguments) 55 | return await api.session.transaction(operation.bind(this), args) 56 | } 57 | }, 58 | 59 | getConnection () { 60 | return api.session.getConnection() 61 | }, 62 | 63 | get session () { 64 | var current = DOMAIN_TO_SESSION.get(process.domain) 65 | if (!current || current.inactive || !process.domain) { 66 | throw new NoSessionAvailable() 67 | } 68 | return current 69 | }, 70 | 71 | NoSessionAvailable 72 | } 73 | 74 | // how does this nest: 75 | // 1. no transaction — session creates connections on-demand up till maxconcurrency 76 | // 2. transaction — session holds one connection, gives it to requesters as-needed, one 77 | // at a time 78 | // 3. atomic — grouped set of operations — parent transaction treats all connections performed 79 | // as a single operation 80 | class Session { 81 | constructor (getConnection, opts) { 82 | this._getConnection = getConnection 83 | this.activeConnections = 0 84 | this.maxConcurrency = opts.maxConcurrency || Infinity 85 | this.metrics = { 86 | onSubsessionStart: opts.onSubsessionStart, 87 | onSubsessionFinish: opts.onSubsessionFinish, 88 | onSessionIdle: opts.onSessionIdle, 89 | onConnectionRequest: opts.onConnectionRequest, 90 | onConnectionStart: opts.onConnectionStart, 91 | onConnectionFinish: opts.onConnectionFinish, 92 | onTransactionRequest: opts.onTransactionRequest, 93 | onTransactionStart: opts.onTransactionStart, 94 | onTransactionFinish: opts.onTransactionFinish, 95 | onTransactionConnectionRequest: opts.onTransactionConnectionRequest, 96 | onTransactionConnectionStart: opts.onTransactionConnectionStart, 97 | onTransactionConnectionFinish: opts.onTransactionConnectionFinish, 98 | onAtomicRequest: opts.onAtomicRequest, 99 | onAtomicStart: opts.onAtomicStart, 100 | onAtomicFinish: opts.onAtomicFinish 101 | } 102 | this.pending = [] 103 | } 104 | 105 | getConnection () { 106 | const baton = {} 107 | this.metrics.onConnectionRequest(baton) 108 | if (this.activeConnections === this.maxConcurrency) { 109 | // not using Promise.defer() here in case it gets deprecated by 110 | // bluebird. 111 | const pending = _defer() 112 | this.pending.push(pending) 113 | return pending.promise 114 | } 115 | 116 | const connPair = Promise.resolve(this._getConnection()) 117 | ++this.activeConnections 118 | 119 | return connPair.then(pair => { 120 | this.metrics.onConnectionStart(baton) 121 | return new SessionConnectionPair(pair, this, baton) 122 | }) 123 | } 124 | 125 | transaction (operation, args) { 126 | const baton = {} 127 | const getConnPair = this.getConnection() 128 | this.metrics.onTransactionRequest(baton, operation, args) 129 | const getResult = Session$RunWrapped(this, connPair => { 130 | this.metrics.onTransactionStart(baton, operation, args) 131 | return new TransactionSession(connPair, this.metrics) 132 | }, getConnPair, `BEGIN`, { 133 | success: `COMMIT`, 134 | failure: `ROLLBACK` 135 | }, operation, args) 136 | 137 | const releasePair = getConnPair.then(pair => { 138 | return getResult.then(result => { 139 | this.metrics.onTransactionFinish(baton, operation, args, result) 140 | return pair.release() 141 | }).catch(reason => { 142 | this.metrics.onTransactionFinish(baton, operation, args, reason) 143 | return pair.release(reason) 144 | }) 145 | }) 146 | 147 | return releasePair.then(() => getResult) 148 | } 149 | 150 | atomic (operation, args) { 151 | return this.transaction(() => { 152 | return DOMAIN_TO_SESSION.get(process.domain).atomic(operation, args) 153 | }, args.slice()) 154 | } 155 | 156 | releasePair (pair, err) { 157 | --this.activeConnections 158 | pair.release(err) 159 | } 160 | } 161 | 162 | class TransactionSession { 163 | constructor (connPair, metrics) { 164 | this.connectionPair = connPair 165 | this.inactive = false 166 | this.operation = Promise.resolve(true) 167 | this.metrics = metrics 168 | } 169 | 170 | getConnection () { 171 | if (this.inactive) { 172 | return new Promise((resolve, reject) => { 173 | reject(new NoSessionAvailable()) 174 | }) 175 | } 176 | 177 | const baton = {} 178 | this.metrics.onTransactionConnectionRequest(baton) 179 | // NB(chrisdickinson): creating a TxConnPair implicitly 180 | // swaps out "this.operation", creating a linked list of 181 | // promises. 182 | return new TxSessionConnectionPair(this, baton).onready 183 | } 184 | 185 | transaction (operation, args) { 186 | if (this.inactive) { 187 | return new Promise((resolve, reject) => { 188 | reject(new NoSessionAvailable()) 189 | }) 190 | } 191 | return operation.apply(null, args) 192 | } 193 | 194 | atomic (operation, args) { 195 | const baton = {} 196 | const atomicConnPair = this.getConnection() 197 | const savepointName = getSavepointName(operation) 198 | this.metrics.onAtomicRequest(baton, operation, args) 199 | const getResult = Session$RunWrapped(this, connPair => { 200 | this.metrics.onAtomicStart(baton, operation, args) 201 | return new AtomicSession(connPair, this.metrics, savepointName) 202 | }, atomicConnPair, `SAVEPOINT ${savepointName}`, { 203 | success: `RELEASE SAVEPOINT ${savepointName}`, 204 | failure: `ROLLBACK TO SAVEPOINT ${savepointName}` 205 | }, operation, args) 206 | 207 | const releasePair = atomicConnPair.then(pair => { 208 | return getResult.then(result => { 209 | this.metrics.onAtomicFinish(baton, operation, args, result) 210 | return pair.release() 211 | }).catch(reason => { 212 | this.metrics.onAtomicFinish(baton, operation, args, reason) 213 | return pair.release(reason) 214 | }) 215 | }) 216 | 217 | return releasePair.then(() => getResult) 218 | } 219 | 220 | // NB: for use in tests _only_!) 221 | assign (domain) { 222 | DOMAIN_TO_SESSION.set(domain, this) 223 | } 224 | } 225 | 226 | class AtomicSession extends TransactionSession { 227 | constructor (connection, metrics, name) { 228 | super(connection, metrics) 229 | this.name = name 230 | } 231 | } 232 | 233 | function Session$RunWrapped( 234 | parent, 235 | createSession, 236 | getConnPair, 237 | before, 238 | after, 239 | operation, 240 | args 241 | ) { 242 | return getConnPair.then((pair) => { 243 | const subdomain = domain.create() 244 | const session = createSession(pair) 245 | parent.metrics.onSubsessionStart(parent, session) 246 | DOMAIN_TO_SESSION.set(subdomain, session) 247 | 248 | const runBefore = new Promise((resolve, reject) => { 249 | return pair.connection.query(before, (err) => 250 | err ? reject(err) : resolve() 251 | ) 252 | }) 253 | 254 | return runBefore.then(() => { 255 | const getResult = Promise.resolve( 256 | subdomain.run(() => { 257 | return Promise.resolve().then(() => { 258 | return operation.apply(null, args) 259 | }) 260 | }) 261 | ) 262 | 263 | const waitOperation = getResult 264 | .then((result) => { 265 | return Promise.all([ 266 | Promise.resolve(result), 267 | Promise.resolve(session.operation), 268 | ]) 269 | }) 270 | .finally(() => { 271 | markInactive(subdomain) 272 | }) 273 | 274 | const runCommitStep = waitOperation 275 | .then(([result]) => { 276 | return new Promise((resolve, reject) => { 277 | return pair.connection.query( 278 | result ? after.success : after.failure, 279 | (err) => (err ? reject(err) : resolve()) 280 | ) 281 | }) 282 | }) 283 | .then( 284 | () => parent.metrics.onSubsessionFinish(parent, session), 285 | (err) => { 286 | parent.metrics.onSubsessionFinish(parent, session) 287 | throw err 288 | } 289 | ) 290 | return runCommitStep.then(() => getResult) 291 | }) 292 | }) 293 | } 294 | 295 | function getSavepointName (operation) { 296 | const id = getSavepointName.ID++ 297 | const dt = new Date().toISOString().replace(/[^\d]/g, '_').slice(0, -1) 298 | const name = (operation.name || 'anon').replace(/[^\w]/g, '_') 299 | // e.g., "save_13_userToOrg_2016_01_03_08_30_00_000" 300 | return `save_${id}_${name}_${dt}` 301 | } 302 | getSavepointName.ID = 0 303 | 304 | function markInactive (subdomain) { 305 | return () => { 306 | subdomain.exit() 307 | DOMAIN_TO_SESSION.get(subdomain).inactive = true 308 | DOMAIN_TO_SESSION.set(subdomain, null) 309 | } 310 | } 311 | 312 | function _defer () { 313 | const pending = { 314 | resolve: null, 315 | reject: null, 316 | promise: null 317 | } 318 | pending.promise = new Promise((resolve, reject) => { 319 | pending.resolve = resolve 320 | pending.reject = reject 321 | }) 322 | return pending 323 | } 324 | -------------------------------------------------------------------------------- /lib/domain.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* eslint-disable node/no-deprecated-api */ 4 | module.exports = require('domain') 5 | -------------------------------------------------------------------------------- /lib/session-connpair.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = class SessionConnectionPair { 4 | constructor (connPair, session, baton) { 5 | this.pair = connPair 6 | this.session = session 7 | this.baton = baton 8 | 9 | // tightly bind "release", because we don't know 10 | // who will be calling it. 11 | this.release = err => release(this, err) 12 | } 13 | 14 | get connection () { 15 | return this.pair.connection 16 | } 17 | } 18 | 19 | // release: attempt to hand the connection pair to the next 20 | // in the list of waiting receivers. If there are none, release 21 | // the connection entirely. This lets us limit the concurrency 22 | // per-request, instead of globally. 23 | function release (conn, err) { 24 | conn.session.metrics.onConnectionFinish(conn.baton, err) 25 | if (err) { 26 | return handleError(conn, err) 27 | } 28 | const next = conn.session.pending.shift() 29 | if (next) { 30 | return next.resolve(conn) 31 | } 32 | conn.session.metrics.onSessionIdle() 33 | conn.session.releasePair(conn.pair, null) 34 | } 35 | 36 | // handleError: release the connection back to the pg pool 37 | // with the error notification; replay all pending connections 38 | // so they don't try to grab this one. 39 | function handleError (conn, err) { 40 | conn.session.releasePair(conn.pair, err) 41 | 42 | const pending = conn.session.pending.slice() 43 | conn.session.pending.length = 0 44 | pending.forEach(xs => { 45 | conn.session.getConnection().then(ys => xs.resolve(ys)) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /lib/tx-session-connpair.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const RESOLVE_SYM = Symbol('resolve') 4 | const REJECT_SYM = Symbol('reject') 5 | 6 | // while SessionConnectionPair (./session-connpair.js) represents 7 | // an *actual* postgres connection and "release" function, 8 | // TransactionSessionConnPair *wraps* a SessionConnectionPair in 9 | // order to serialize access to it. As such, "txPair.release()" 10 | // doesn't release the underlying connection, it just lets 11 | // subsequent operations run. 12 | // 13 | // the session machinery handles "fully" releasing the underlying 14 | // connection — see Session#transaction and TransactionSession#atomic 15 | // for more details (specifically, look for "release()".) 16 | module.exports = class TransactionSessionConnPair { 17 | constructor (session, baton) { 18 | const metrics = session.metrics 19 | this.pair = session.connectionPair 20 | this.release = err => release(this, metrics, baton, err) 21 | this.completed = new Promise((resolve, reject) => { 22 | this[RESOLVE_SYM] = resolve 23 | this[REJECT_SYM] = reject 24 | }).catch((/* err */) => { 25 | // XXX(chrisdickinson): this would be the place to 26 | // add error monitoring. 27 | }) 28 | 29 | // the onready promise will let session methods know 30 | // that previous operations have fully resolved — 31 | // "session.operation" is "txPair.completed", so we end 32 | // up with a linked list of promises, e.g.: 33 | // 34 | // duration of one transaction 35 | // _ _ _ _ _ _ _|_ _ _ _ _ _ _ 36 | // / \ 37 | // | | 38 | // completed → onready → (work happens) → completed → onready 39 | // 40 | this.onready = session.operation.then(() => { 41 | metrics.onTransactionConnectionStart(baton) 42 | return this 43 | }) 44 | session.operation = this.completed 45 | } 46 | 47 | get connection () { 48 | return this.pair.connection 49 | } 50 | } 51 | 52 | function release (conn, metrics, baton, err) { 53 | metrics.onTransactionConnectionFinish(baton, err) 54 | return err ? conn[REJECT_SYM](err) : conn[RESOLVE_SYM]() 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@npm/pg-db-session", 3 | "version": "1.5.0", 4 | "description": "domain-attached database sessions", 5 | "main": "db-session.js", 6 | "scripts": { 7 | "test": "tap ${TAP_FLAGS:-'--'} test/*-test.js", 8 | "posttest": "standard", 9 | "cov:test": "TAP_FLAGS='--cov' npm test", 10 | "cov:view": "TAP_FLAGS='--coverage-report=html' npm test" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+ssh://git@github.com/npm/pg-db-session.git" 15 | }, 16 | "keywords": [ 17 | "postgres", 18 | "database", 19 | "domain", 20 | "session" 21 | ], 22 | "author": "Chris Dickinson (http://neversaw.us/)", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/npm/pg-db-session/issues" 26 | }, 27 | "homepage": "https://github.com/npm/pg-db-session#readme", 28 | "devDependencies": { 29 | "pg": "6.1.6", 30 | "standard": "10.0.2", 31 | "tap": "10.3.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/basic-api-errors-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | 5 | const domain = require('../lib/domain.js') 6 | const db = require('../db-session.js') 7 | 8 | test('test transaction outside of session', assert => { 9 | const testTransaction = db.transaction(function testTransaction () { 10 | }) 11 | 12 | testTransaction() 13 | .then(() => { throw new Error('expected error') }) 14 | .catch(err => { 15 | if(err instanceof db.NoSessionAvailable) { 16 | assert.end() 17 | } 18 | assert.end(err) 19 | }) 20 | }) 21 | 22 | test('test atomic outside of session', assert => { 23 | const testAtomic = db.atomic(function testAtomic () { 24 | }) 25 | 26 | testAtomic() 27 | .then(() => { throw new Error('expected error') }) 28 | .catch(err => { 29 | if(err instanceof db.NoSessionAvailable) { 30 | assert.end() 31 | } 32 | assert.end(err) 33 | }) 34 | }) 35 | 36 | test('test getConnection after release', assert => { 37 | const domain1 = domain.create() 38 | 39 | db.install(domain1, getConnection, {maxConcurrency: 0}) 40 | 41 | domain1.run(() => { 42 | return db.transaction(() => { 43 | const session = db.session 44 | setImmediate(() => { 45 | session.getConnection() 46 | .then(pair => { throw new Error('should not reach here') }) 47 | .catch(err => { 48 | if(err instanceof db.NoSessionAvailable) { 49 | assert.ok(1, 'caught err') 50 | } 51 | assert.fail(err) 52 | }) 53 | .finally(assert.end) 54 | }) 55 | })() 56 | }) 57 | .catch(err => assert.fail(err)) 58 | .finally(() => domain1.exit()) 59 | 60 | function getConnection () { 61 | return { 62 | connection: {query (sql, ready) { 63 | return ready() 64 | }}, 65 | release () { 66 | } 67 | } 68 | } 69 | }) 70 | 71 | test('test transaction after release', assert => { 72 | const domain1 = domain.create() 73 | 74 | db.install(domain1, getConnection, {maxConcurrency: 0}) 75 | 76 | domain1.run(() => { 77 | return db.transaction(() => { 78 | const session = db.session 79 | setImmediate(() => { 80 | session.transaction(() => {}) 81 | .then(pair => { throw new Error('should not reach here') }) 82 | .catch(err => { 83 | if(err instanceof db.NoSessionAvailable) { 84 | assert.ok(1, 'caught err') 85 | } 86 | assert.fail(err) 87 | }) 88 | .finally(assert.end) 89 | }) 90 | })() 91 | }) 92 | .catch(err => assert.fail(err)) 93 | .finally(() => domain1.exit()) 94 | 95 | function getConnection () { 96 | return { 97 | connection: {query (sql, ready) { 98 | return ready() 99 | }}, 100 | release () { 101 | } 102 | } 103 | } 104 | }) 105 | -------------------------------------------------------------------------------- /test/basic-atomic-concurrency-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | 5 | const domain = require('../lib/domain.js') 6 | const db = require('../db-session.js') 7 | const delay = require('../utils/delay') 8 | 9 | const LOGS = [] 10 | 11 | const runOperations = db.transaction(function runOperations (inner) { 12 | return Promise.all(Array.from(Array(4)).map((_, idx) => { 13 | return idx % 2 === 0 ? inner(idx) : db.getConnection().then(connPair => { 14 | LOGS.push(`load ${idx}`) 15 | return delay(5).then(() => { 16 | LOGS.push(`release ${idx}`) 17 | connPair.release() 18 | }) 19 | }) 20 | })) 21 | }) 22 | 23 | function runSubOperation (rootIdx) { 24 | return Promise.all(Array.from(Array(4)).map((_, idx) => { 25 | return delay(5).then(() => { 26 | return db.getConnection().then(connPair => { 27 | LOGS.push(`load ${rootIdx} ${idx}`) 28 | return delay(5).then(() => { 29 | LOGS.push(`release ${rootIdx} ${idx}`) 30 | connPair.release() 31 | }) 32 | }) 33 | }) 34 | })) 35 | } 36 | 37 | const txRunSubOperation = db.transaction(runSubOperation) 38 | const atomicRunSubOperation = db.atomic(runSubOperation) 39 | 40 | test('test nested transaction order', assert => { 41 | LOGS.length = 0 42 | const start = process.domain 43 | const domain1 = domain.create() 44 | db.install(domain1, innerGetConnection, {maxConcurrency: 0}) 45 | domain1.run(() => { 46 | return runOperations(txRunSubOperation) 47 | }).then(() => { 48 | assert.equal(process.domain, start) 49 | }).then(() => { 50 | assert.equal(LOGS.join('\n'), ` 51 | BEGIN 52 | load 1 53 | release 1 54 | load 3 55 | release 3 56 | load 0 0 57 | release 0 0 58 | load 0 1 59 | release 0 1 60 | load 0 2 61 | release 0 2 62 | load 0 3 63 | release 0 3 64 | load 2 0 65 | release 2 0 66 | load 2 1 67 | release 2 1 68 | load 2 2 69 | release 2 2 70 | load 2 3 71 | release 2 3 72 | COMMIT 73 | release 74 | `.trim()) 75 | assert.equal(process.domain, start) 76 | }) 77 | .catch(err => assert.fail(err.stack)) 78 | .finally(() => domain1.exit()) 79 | .finally(assert.end) 80 | }) 81 | 82 | test('test nested atomic transaction order', assert => { 83 | LOGS.length = 0 84 | const start = process.domain 85 | const domain1 = domain.create() 86 | db.install(domain1, innerGetConnection, {maxConcurrency: 0}) 87 | domain1.run(() => { 88 | return runOperations(atomicRunSubOperation) 89 | }).then(() => { 90 | assert.equal(process.domain, start) 91 | }).then(() => { 92 | assert.equal(LOGS.join('\n').replace(/_[\d_]+$/gm, '_TS'), ` 93 | BEGIN 94 | SAVEPOINT save_0_bound_runSubOperation_TS 95 | load 0 0 96 | release 0 0 97 | load 0 1 98 | release 0 1 99 | load 0 2 100 | release 0 2 101 | load 0 3 102 | release 0 3 103 | RELEASE SAVEPOINT save_0_bound_runSubOperation_TS 104 | load 1 105 | release 1 106 | SAVEPOINT save_1_bound_runSubOperation_TS 107 | load 2 0 108 | release 2 0 109 | load 2 1 110 | release 2 1 111 | load 2 2 112 | release 2 2 113 | load 2 3 114 | release 2 3 115 | RELEASE SAVEPOINT save_1_bound_runSubOperation_TS 116 | load 3 117 | release 3 118 | COMMIT 119 | release 120 | `.trim()) 121 | assert.equal(process.domain, start) 122 | }) 123 | .catch(err => assert.fail(err.stack)) 124 | .finally(() => domain1.exit()) 125 | .finally(assert.end) 126 | }) 127 | 128 | function innerGetConnection () { 129 | return { 130 | connection: {query (sql, ready) { 131 | LOGS.push(sql) 132 | return ready() 133 | }}, 134 | release () { 135 | LOGS.push(`release`) 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /test/basic-atomic-error-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const { promisify } = require('utils') 5 | 6 | const domain = require('../lib/domain.js') 7 | const db = require('../db-session.js') 8 | 9 | // what happens if there's an error in the previous query? 10 | // - a failed query should not automatically end the atomic 11 | // - only returning a promise will end the atomic 12 | test('test error in previous query', assert => { 13 | const domain1 = domain.create() 14 | 15 | db.install(domain1, getConnection, {maxConcurrency: 0}) 16 | 17 | domain1.run(() => { 18 | return db.atomic(() => { 19 | const first = db.getConnection().then(conn => { 20 | const queryAsync = promisify(conn.connection.query).bind(conn.connection) 21 | return queryAsync('ONE') 22 | .then(() => conn.release()) 23 | .catch(err => { 24 | conn.release(err) 25 | throw err 26 | }) 27 | }) 28 | 29 | const second = first.then(() => { 30 | return db.getConnection() 31 | }).then(conn => { 32 | const queryAsync = promisify(conn.connection.query).bind(conn.connection) 33 | return queryAsync('TWO') 34 | .then(() => conn.release()) 35 | .catch(err => { 36 | conn.release(err) 37 | throw err 38 | }) 39 | }) 40 | 41 | return second.then(() => 'expect this value') 42 | })() 43 | }) 44 | .then(value => assert.equal(value, 'expect this value')) 45 | .catch(err => assert.fail(err.stack)) 46 | .finally(() => domain1.exit()) 47 | .finally(assert.end) 48 | 49 | function getConnection () { 50 | return { 51 | connection: {query (sql, ready) { 52 | if (sql === 'ONE') { 53 | return ready(new Error('failed')) 54 | } 55 | return ready() 56 | }}, 57 | release () { 58 | } 59 | } 60 | } 61 | }) 62 | 63 | // what happens if BEGIN fails 64 | test('test error in BEGIN', assert => { 65 | const domain1 = domain.create() 66 | class BeginError extends Error {} 67 | 68 | db.install(domain1, getConnection, {maxConcurrency: 0}) 69 | 70 | domain1.run(() => { 71 | return db.atomic(() => { 72 | assert.fail('should not reach here.') 73 | })() 74 | }) 75 | .catch(err => { 76 | if(err instanceof BeginError) { 77 | assert.ok(1, 'caught expected err') 78 | } 79 | assert.fail(err) 80 | }) 81 | .finally(() => domain1.exit()) 82 | .finally(assert.end) 83 | 84 | function getConnection () { 85 | var trippedBegin = false 86 | return { 87 | connection: {query (sql, ready) { 88 | if (trippedBegin) { 89 | assert.fail('should not run subsequent queries') 90 | } 91 | if (sql === 'BEGIN') { 92 | trippedBegin = true 93 | return ready(new BeginError('failed BEGIN')) 94 | } 95 | return ready() 96 | }}, 97 | release () { 98 | } 99 | } 100 | } 101 | }) 102 | 103 | // what happens if COMMIT / ROLLBACK fails 104 | test('test error in COMMIT', assert => { 105 | const domain1 = domain.create() 106 | class CommitError extends Error {} 107 | 108 | db.install(domain1, getConnection, {maxConcurrency: 0}) 109 | 110 | domain1.run(() => { 111 | return db.atomic(() => { 112 | return db.getConnection().then(pair => pair.release()) 113 | })() 114 | }) 115 | .catch(err => { 116 | if(err instanceof CommitError) { 117 | assert.ok(1, 'caught expected error') 118 | } 119 | assert.fail(err) 120 | }) 121 | .finally(() => domain1.exit()) 122 | .finally(assert.end) 123 | 124 | function getConnection () { 125 | return { 126 | connection: {query (sql, ready) { 127 | if (sql === 'COMMIT') { 128 | return ready(new CommitError('failed COMMIT')) 129 | } 130 | return ready() 131 | }}, 132 | release () { 133 | } 134 | } 135 | } 136 | }) 137 | 138 | test('test error in ROLLBACK: does not reuse connection', assert => { 139 | const domain1 = domain.create() 140 | class RollbackError extends Error {} 141 | 142 | db.install(domain1, getConnection, {maxConcurrency: 1}) 143 | 144 | var connectionPair = null 145 | domain1.run(() => { 146 | const first = db.atomic(() => { 147 | return db.getConnection().then(pair => { 148 | connectionPair = pair.pair 149 | pair.release() 150 | throw new Error('any kind of error, really') 151 | }) 152 | })().catch(() => undefined) 153 | 154 | const second = db.getConnection().then(pair => { 155 | // with concurrency=1, we will try to re-use 156 | // the connection if we can. since we had an error, 157 | // it's best not to use the connection! 158 | assert.notEqual(connectionPair, pair) 159 | pair.release() 160 | }) 161 | 162 | return Promise.all([first, second]) 163 | }) 164 | .catch(err => assert.fail(err)) 165 | .finally(() => domain1.exit()) 166 | .finally(assert.end) 167 | 168 | function getConnection () { 169 | return { 170 | connection: {query (sql, ready) { 171 | if (sql === 'ROLLBACK') { 172 | return ready(new RollbackError('failed ROLLBACK')) 173 | } 174 | return ready() 175 | }}, 176 | release () { 177 | } 178 | } 179 | } 180 | }) -------------------------------------------------------------------------------- /test/basic-atomic-from-session-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | 5 | const domain = require('../lib/domain.js') 6 | const db = require('../db-session.js') 7 | 8 | const LOGS = [] 9 | 10 | const runOperation = db.atomic(function runOperation (inner) { 11 | return db.getConnection().then(pair => { 12 | pair.release() 13 | }) 14 | }) 15 | 16 | // we're making sure that if we're in a session, we can 17 | // jump directly to an atomic from the session, without 18 | // having to explicitly start a transaction in-between. 19 | test('test immediate atomic', assert => { 20 | LOGS.length = 0 21 | const start = process.domain 22 | const domain1 = domain.create() 23 | db.install(domain1, innerGetConnection, {maxConcurrency: 0}) 24 | domain1.run(() => { 25 | return runOperation() 26 | }).then(() => { 27 | assert.equal(process.domain, start) 28 | domain1.exit() 29 | }).then(() => { 30 | assert.equal(LOGS.join('\n').replace(/_[\d_]+$/gm, '_TS'), ` 31 | BEGIN 32 | SAVEPOINT save_0_bound_runOperation_TS 33 | RELEASE SAVEPOINT save_0_bound_runOperation_TS 34 | COMMIT 35 | release 36 | `.trim()) 37 | assert.equal(process.domain, start) 38 | }).then(() => assert.end()) 39 | .catch(assert.end) 40 | }) 41 | 42 | function innerGetConnection () { 43 | return { 44 | connection: {query (sql, ready) { 45 | LOGS.push(sql) 46 | return ready() 47 | }}, 48 | release () { 49 | LOGS.push(`release`) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/basic-interleaved-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const fs = require('fs') 5 | 6 | const domain = require('../lib/domain.js') 7 | const db = require('../db-session.js') 8 | 9 | // this is pretty much just testing domains 10 | test('test of interleaved requests', assert => { 11 | const domain1 = domain.create() 12 | const domain2 = domain.create() 13 | 14 | db.install(domain1, getFakeConnection) 15 | db.install(domain2, getFakeConnection) 16 | 17 | var pending = 3 18 | 19 | domain1.run(() => { 20 | const firstSession = db.session 21 | fs.readFile(__filename, () => { 22 | assert.equal(firstSession, db.session) 23 | domain2.run(() => { 24 | assert.ok(firstSession !== db.session) 25 | const secondSession = db.session 26 | setTimeout(() => { 27 | assert.equal(secondSession, db.session) 28 | !--pending && end() 29 | }, 100) 30 | fs.readFile(__filename, () => { 31 | assert.equal(secondSession, db.session) 32 | !--pending && end() 33 | }) 34 | }) 35 | setTimeout(() => { 36 | assert.equal(firstSession, db.session) 37 | !--pending && end() 38 | }, 100) 39 | }) 40 | }) 41 | 42 | function end () { 43 | process.domain.exit() 44 | assert.end() 45 | } 46 | 47 | function getFakeConnection () { 48 | return { 49 | connection: {query () { 50 | }}, 51 | release () { 52 | } 53 | } 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /test/basic-rollback-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | 5 | const domain = require('../lib/domain.js') 6 | const db = require('../db-session.js') 7 | 8 | const LOGS = [] 9 | 10 | test('rolling back transaction calls ROLLBACK', assert => { 11 | const domain1 = domain.create() 12 | 13 | LOGS.length = 0 14 | db.install(domain1, getConnection, {maxConcurrency: 0}) 15 | 16 | domain1.run(() => { 17 | return db.transaction(() => { 18 | throw new Error('no thanks') 19 | })().catch(() => undefined) 20 | }) 21 | .then(() => assert.equal(LOGS.join(' '), 'BEGIN ROLLBACK')) 22 | .catch(err => assert.fail(err)) 23 | .finally(() => domain1.exit()) 24 | .finally(assert.end) 25 | 26 | function getConnection () { 27 | return { 28 | connection: {query (sql, ready) { 29 | LOGS.push(sql) 30 | return ready() 31 | }}, 32 | release () { 33 | } 34 | } 35 | } 36 | }) 37 | 38 | test('rolling back atomic calls ROLLBACK', assert => { 39 | const domain1 = domain.create() 40 | 41 | LOGS.length = 0 42 | db.install(domain1, getConnection, {maxConcurrency: 0}) 43 | 44 | domain1.run(() => { 45 | return db.atomic(() => { 46 | throw new Error('no thanks') 47 | })().catch(() => undefined) 48 | }) 49 | .then(() => { 50 | assert.equal(LOGS.join('\n').replace(/_[\d_]+$/gm, '_TS'), ` 51 | BEGIN 52 | SAVEPOINT save_0_bound_TS 53 | ROLLBACK TO SAVEPOINT save_0_bound_TS 54 | ROLLBACK 55 | `.trim()) 56 | }) 57 | .catch(err => assert.fail(err)) 58 | .finally(() => domain1.exit()) 59 | .finally(assert.end) 60 | 61 | function getConnection () { 62 | return { 63 | connection: {query (sql, ready) { 64 | LOGS.push(sql) 65 | return ready() 66 | }}, 67 | release () { 68 | } 69 | } 70 | } 71 | }) 72 | -------------------------------------------------------------------------------- /test/basic-session-concurrency-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | 5 | const domain = require('../lib/domain.js') 6 | const db = require('../db-session.js') 7 | const delay = require('../utils/delay') 8 | 9 | const LOGS = [] 10 | 11 | 12 | test('test root session concurrency=0', assert => { 13 | const start = process.domain 14 | const domain1 = domain.create() 15 | db.install(domain1, innerGetConnection, {maxConcurrency: 0}) 16 | domain1.run(() => { 17 | return runOperations() 18 | }).then(() => { 19 | domain1.exit() 20 | }).then(() => { 21 | assert.equal(LOGS.join('\n'), ` 22 | load 0 23 | load 1 24 | load 2 25 | load 3 26 | load 4 27 | load 5 28 | load 6 29 | load 7 30 | release 0 31 | release 32 | release 1 33 | release 34 | release 2 35 | release 36 | release 3 37 | release 38 | release 4 39 | release 40 | release 5 41 | release 42 | release 6 43 | release 44 | release 7 45 | release 46 | `.trim()) 47 | assert.equal(process.domain, start) 48 | }).then(() => assert.end()) 49 | .catch(assert.end) 50 | }) 51 | 52 | test('test root session concurrency=1', assert => { 53 | const start = process.domain 54 | const domain1 = domain.create() 55 | db.install(domain1, innerGetConnection, {maxConcurrency: 1}) 56 | domain1.run(() => { 57 | return runOperations() 58 | }).then(() => { 59 | domain1.exit() 60 | }).then(() => { 61 | assert.equal(LOGS.join('\n'), ` 62 | load 0 63 | release 0 64 | load 1 65 | release 1 66 | load 2 67 | release 2 68 | load 3 69 | release 3 70 | load 4 71 | release 4 72 | load 5 73 | release 5 74 | load 6 75 | release 6 76 | load 7 77 | release 7 78 | release 79 | `.trim()) 80 | assert.equal(process.domain, start) 81 | }).then(() => assert.end()) 82 | .catch(assert.end) 83 | }) 84 | 85 | test('test root session concurrency=2', assert => { 86 | const start = process.domain 87 | const domain1 = domain.create() 88 | db.install(domain1, innerGetConnection, {maxConcurrency: 2}) 89 | domain1.run(() => { 90 | return runOperations() 91 | }).then(() => { 92 | domain1.exit() 93 | }).then(() => { 94 | assert.equal(LOGS.join('\n'), ` 95 | load 0 96 | load 1 97 | release 0 98 | release 1 99 | load 2 100 | load 3 101 | release 2 102 | release 3 103 | load 4 104 | load 5 105 | release 4 106 | release 5 107 | load 6 108 | load 7 109 | release 6 110 | release 111 | release 7 112 | release 113 | `.trim()) 114 | assert.equal(process.domain, start) 115 | }).then(() => assert.end()) 116 | .catch(assert.end) 117 | }) 118 | 119 | test('test root session concurrency=4', assert => { 120 | const start = process.domain 121 | const domain1 = domain.create() 122 | db.install(domain1, innerGetConnection, {maxConcurrency: 4}) 123 | domain1.run(() => { 124 | return runOperations() 125 | }).then(() => { 126 | domain1.exit() 127 | }).then(() => { 128 | assert.equal(LOGS.join('\n'), ` 129 | load 0 130 | load 1 131 | load 2 132 | load 3 133 | release 0 134 | release 1 135 | release 2 136 | release 3 137 | load 4 138 | load 5 139 | load 6 140 | load 7 141 | release 4 142 | release 143 | release 5 144 | release 145 | release 6 146 | release 147 | release 7 148 | release 149 | `.trim()) 150 | assert.equal(process.domain, start) 151 | }).then(() => assert.end()) 152 | .catch(assert.end) 153 | }) 154 | 155 | function innerGetConnection () { 156 | return { 157 | connection: {query () { 158 | }}, 159 | release () { 160 | LOGS.push(`release`) 161 | } 162 | } 163 | } 164 | 165 | function runOperations () { 166 | LOGS.length = 0 167 | return Promise.all(Array.from(Array(8)).map((_, idx) => { 168 | return db.getConnection().then(connPair => { 169 | LOGS.push(`load ${idx}`) 170 | return delay(5).then(() => { 171 | LOGS.push(`release ${idx}`) 172 | connPair.release() 173 | }) 174 | }) 175 | })) 176 | } 177 | -------------------------------------------------------------------------------- /test/basic-session-error-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | 5 | const domain = require('../lib/domain.js') 6 | const db = require('../db-session.js') 7 | 8 | const LOGS = [] 9 | var shouldErrorToggle = false 10 | 11 | // what happens when there's an error connecting? 12 | test('cannot connect', assert => { 13 | LOGS.length = 0 14 | const domain1 = domain.create() 15 | class TestError extends Error {} 16 | 17 | db.install(domain1, () => new Promise((resolve, reject) => { 18 | reject(new TestError('cannot connect')) 19 | }), {maxConcurrency: 0}) 20 | 21 | domain1.run(() => { 22 | return db.getConnection().then(pair => { 23 | pair.release() 24 | }) 25 | }).then(() => { 26 | throw new Error('expected an exception') 27 | }).catch(err => { 28 | if(err instanceof TestError) { 29 | assert.ok(1, 'saw exception') 30 | } else { 31 | throw err 32 | } 33 | }).then(() => { 34 | domain1.exit() 35 | }).then(() => assert.end()) 36 | .catch(assert.end) 37 | }) 38 | 39 | // what happens when there's an error querying? 40 | test('query error', assert => { 41 | LOGS.length = 0 42 | const domain1 = domain.create() 43 | 44 | db.install(domain1, innerGetConnection, {maxConcurrency: 0}) 45 | shouldErrorToggle = new Error('the kraken') 46 | 47 | domain1.run(() => { 48 | return db.getConnection().then(pair => { 49 | return new Promise((resolve, reject) => { 50 | pair.connection.query('FAKE QUERY', err => { 51 | err ? reject(err) : resolve() 52 | }) 53 | }).then(pair.release, pair.release) 54 | }) 55 | }).then(() => { 56 | assert.ok(true, 'pair.release does not rethrow') 57 | assert.equal(LOGS.join(' '), 'FAKE QUERY release the kraken') 58 | }) 59 | .catch(err => assert.fail(err)) 60 | .finally(() => domain1.exit()) 61 | .finally(assert.end) 62 | }) 63 | 64 | // what happens to pending connections when there's an error? 65 | test('query error: pending connections', assert => { 66 | LOGS.length = 0 67 | const domain1 = domain.create() 68 | class TestError extends Error {} 69 | 70 | db.install(domain1, innerGetConnection, {maxConcurrency: 1}) 71 | shouldErrorToggle = new TestError('the beast') 72 | 73 | var firstConnection = null 74 | var secondConnection = null 75 | domain1.run(() => { 76 | return Promise.all([ 77 | db.getConnection().then(pair => { 78 | firstConnection = pair 79 | return new Promise((resolve, reject) => { 80 | pair.connection.query('FAKE QUERY', err => { 81 | err ? reject(err) : resolve() 82 | }) 83 | }).then(pair.release, pair.release) 84 | }), 85 | db.getConnection().then(pair => { 86 | assert.ok(firstConnection) 87 | assert.notEqual(firstConnection, pair) 88 | secondConnection = pair 89 | return new Promise((resolve, reject) => { 90 | pair.connection.query('FAKE QUERY', err => { 91 | err ? reject(err) : resolve() 92 | }) 93 | }).then(pair.release, pair.release) 94 | }), 95 | db.getConnection().then(pair => { 96 | assert.ok(secondConnection) 97 | assert.equal(secondConnection, pair) 98 | return new Promise((resolve, reject) => { 99 | pair.connection.query('FAKE QUERY', err => { 100 | err ? reject(err) : resolve() 101 | }) 102 | }).then(pair.release, pair.release) 103 | }) 104 | ]) 105 | }) 106 | .catch(err => assert.fail(err)) 107 | .finally(() => domain1.exit()) 108 | .finally(assert.end) 109 | }) 110 | 111 | function innerGetConnection () { 112 | return { 113 | connection: {query (sql, ready) { 114 | LOGS.push(sql) 115 | if (shouldErrorToggle) { 116 | var err = shouldErrorToggle 117 | shouldErrorToggle = false 118 | return ready(err) 119 | } 120 | return ready() 121 | }}, 122 | release (err) { 123 | LOGS.push(`release ${err ? err.message : ''}`) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /test/basic-transaction-concurrency-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | 5 | const domain = require('../lib/domain.js') 6 | const db = require('../db-session.js') 7 | const delay = require('../utils/delay') 8 | 9 | const LOGS = [] 10 | 11 | 12 | const runOperations = db.transaction(function runOperations () { 13 | return Promise.all(Array.from(Array(8)).map((_, idx) => { 14 | return db.getConnection().then(connPair => { 15 | LOGS.push(`load ${idx}`) 16 | return delay(5).then(() => { 17 | LOGS.push(`release ${idx}`) 18 | connPair.release() 19 | }) 20 | }) 21 | })) 22 | }) 23 | 24 | test('test root session concurrency=0', assert => { 25 | // compare to ./basic-session-concurrency-test.js, concurrency=1 — 26 | // operations should load and release in sequence, and be bookended 27 | // by "BEGIN" / "END" 28 | const start = process.domain 29 | const domain1 = domain.create() 30 | db.install(domain1, innerGetConnection, {maxConcurrency: 0}) 31 | domain1.run(() => { 32 | return runOperations() 33 | }).then(() => { 34 | assert.equal(process.domain, start) 35 | domain1.exit() 36 | }).then(() => { 37 | assert.equal(LOGS.join('\n'), ` 38 | BEGIN 39 | load 0 40 | release 0 41 | load 1 42 | release 1 43 | load 2 44 | release 2 45 | load 3 46 | release 3 47 | load 4 48 | release 4 49 | load 5 50 | release 5 51 | load 6 52 | release 6 53 | load 7 54 | release 7 55 | COMMIT 56 | release 57 | `.trim()) 58 | assert.equal(process.domain, start) 59 | }).then(() => assert.end()) 60 | .catch(assert.end) 61 | }) 62 | 63 | function innerGetConnection () { 64 | return { 65 | connection: {query (sql, ready) { 66 | LOGS.push(sql) 67 | return ready() 68 | }}, 69 | release () { 70 | LOGS.push(`release`) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/basic-transaction-error-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { promisify } = require('util') 4 | const test = require('tap').test 5 | 6 | const domain = require('../lib/domain.js') 7 | const db = require('../db-session.js') 8 | 9 | // what happens if there's an error in the previous query? 10 | // - a failed query should not automatically end the transaction 11 | // - only returning a promise will end the transaction 12 | test('test error in previous query', assert => { 13 | const domain1 = domain.create() 14 | 15 | db.install(domain1, getConnection, {maxConcurrency: 0}) 16 | 17 | domain1.run(() => { 18 | return db.transaction(() => { 19 | const first = db.getConnection().then(conn => { 20 | return promisify(conn.connection.query)('ONE') 21 | .then(() => conn.release()) 22 | .catch(err => conn.release(err)) 23 | }) 24 | 25 | const second = first.then(() => { 26 | return db.getConnection() 27 | }).then(conn => { 28 | return promisify(conn.connection.query)('TWO') 29 | .then(() => conn.release()) 30 | .catch(err => conn.release(err)) 31 | }) 32 | 33 | return second.then(() => 'expect this value') 34 | })() 35 | }) 36 | .then(value => assert.equal(value, 'expect this value')) 37 | .catch(err => assert.fail(err.stack)) 38 | .finally(() => domain1.exit()) 39 | .finally(assert.end) 40 | 41 | function getConnection () { 42 | return { 43 | connection: {query (sql, ready) { 44 | if (sql === 'ONE') { 45 | return ready(new Error('failed')) 46 | } 47 | return ready() 48 | }}, 49 | release () { 50 | } 51 | } 52 | } 53 | }) 54 | 55 | // what happens if BEGIN fails 56 | test('test error in BEGIN', assert => { 57 | const domain1 = domain.create() 58 | class BeginError extends Error {} 59 | 60 | db.install(domain1, getConnection, {maxConcurrency: 0}) 61 | 62 | domain1.run(() => { 63 | return db.transaction(() => { 64 | assert.fail('should not reach here.') 65 | })() 66 | }) 67 | .catch(err => { 68 | if(err instanceof BeginError) { 69 | assert.ok(1, 'caught expected err') 70 | } 71 | assert.fail(err) 72 | }) 73 | .finally(() => domain1.exit()) 74 | .finally(assert.end) 75 | 76 | function getConnection () { 77 | var trippedBegin = false 78 | return { 79 | connection: {query (sql, ready) { 80 | if (trippedBegin) { 81 | assert.fail('should not run subsequent queries') 82 | } 83 | if (sql === 'BEGIN') { 84 | trippedBegin = true 85 | return ready(new BeginError('failed BEGIN')) 86 | } 87 | return ready() 88 | }}, 89 | release () { 90 | } 91 | } 92 | } 93 | }) 94 | 95 | // what happens if COMMIT / ROLLBACK fails 96 | test('test error in COMMIT', assert => { 97 | const domain1 = domain.create() 98 | class CommitError extends Error {} 99 | 100 | db.install(domain1, getConnection, {maxConcurrency: 0}) 101 | 102 | domain1.run(() => { 103 | return db.transaction(() => { 104 | return db.getConnection().then(pair => pair.release()) 105 | })() 106 | }) 107 | .catch(err => { 108 | if(err instanceof CommitError) { 109 | assert.ok(1, 'caught expected error') 110 | } 111 | assert.fail(err) 112 | }) 113 | .finally(() => domain1.exit()) 114 | .finally(assert.end) 115 | 116 | function getConnection () { 117 | return { 118 | connection: {query (sql, ready) { 119 | if (sql === 'COMMIT') { 120 | return ready(new CommitError('failed COMMIT')) 121 | } 122 | return ready() 123 | }}, 124 | release () { 125 | } 126 | } 127 | } 128 | }) 129 | 130 | test('test error in ROLLBACK: does not reuse connection', assert => { 131 | const domain1 = domain.create() 132 | class RollbackError extends Error {} 133 | 134 | db.install(domain1, getConnection, {maxConcurrency: 1}) 135 | 136 | var connectionPair = null 137 | domain1.run(() => { 138 | const first = db.transaction(() => { 139 | return db.getConnection().then(pair => { 140 | connectionPair = pair.pair 141 | pair.release() 142 | throw new Error('any kind of error, really') 143 | }) 144 | })().then( 145 | () => null, 146 | () => null 147 | ) 148 | 149 | const second = db.getConnection().then(pair => { 150 | // with concurrency=1, we will try to re-use 151 | // the connection if we can. since we had an error, 152 | // it's best not to use the connection! 153 | assert.notEqual(connectionPair, pair) 154 | pair.release() 155 | }) 156 | 157 | return Promise.all([first, second]) 158 | }) 159 | .catch(err => assert.fail(err)) 160 | .finally(() => domain1.exit()) 161 | .finally(assert.end) 162 | 163 | function getConnection () { 164 | return { 165 | connection: {query (sql, ready) { 166 | if (sql === 'ROLLBACK') { 167 | return ready(new RollbackError('failed ROLLBACK')) 168 | } 169 | return ready() 170 | }}, 171 | release () { 172 | } 173 | } 174 | } 175 | }) 176 | -------------------------------------------------------------------------------- /test/integrate-pg-pool-sequence-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const spawn = require('child_process').spawn 4 | const test = require('tap').test 5 | const pg = require('pg') 6 | 7 | const domain = require('../lib/domain.js') 8 | const db = require('../db-session.js') 9 | 10 | const TEST_DB_NAME = process.env.TEST_DB_NAME || 'pg_db_session_test' 11 | 12 | function setup () { 13 | return teardown().then(() => new Promise(resolve => { 14 | spawn('createdb', [TEST_DB_NAME]).on('exit', resolve) 15 | })) 16 | } 17 | 18 | function teardown () { 19 | return new Promise(resolve => { 20 | spawn('dropdb', [TEST_DB_NAME]).on('exit', resolve) 21 | }) 22 | } 23 | 24 | test('setup', assert => setup().then(assert.end)) 25 | 26 | test('pg pooling does not adversely affect operation', assert => { 27 | const domain1 = domain.create() 28 | const domain2 = domain.create() 29 | 30 | db.install(domain1, getConnection, {maxConcurrency: 0}) 31 | db.install(domain2, getConnection, {maxConcurrency: 0}) 32 | 33 | const runOne = domain1.run(() => runOperation(domain1)) 34 | .then(() => { 35 | domain1.exit() 36 | assert.ok(!process.domain) 37 | }) 38 | 39 | const runTwo = runOne.then(() => { 40 | return domain2.run(() => runOperation(domain2)) 41 | }).then(() => { 42 | domain2.exit() 43 | assert.ok(!process.domain) 44 | }) 45 | 46 | return runTwo 47 | .catch(assert.fail) 48 | .finally(() => pg.end()) 49 | .finally(assert.end) 50 | 51 | function getConnection () { 52 | return new Promise((resolve, reject) => { 53 | pg.connect(`postgres://localhost/${TEST_DB_NAME}`, onconn) 54 | 55 | function onconn (err, connection, release) { 56 | err ? reject(err) : resolve({connection, release}) 57 | } 58 | }) 59 | } 60 | 61 | function runOperation (expectDomain) { 62 | assert.equal(process.domain, expectDomain) 63 | const getConnPair = db.getConnection() 64 | 65 | const runSQL = getConnPair.then(({ connection, release }) => { 66 | assert.equal(process.domain, expectDomain) 67 | return new Promise((resolve, reject) => { 68 | assert.equal(process.domain, expectDomain) 69 | connection.query('SELECT 1', (err, data) => { 70 | assert.equal(process.domain, expectDomain) 71 | if (err) { 72 | reject(err) 73 | } else { 74 | resolve({ data, release }) 75 | } 76 | }) 77 | }) 78 | }) 79 | 80 | return runSQL.then(({ data, release }) => { 81 | release() 82 | return data 83 | }) 84 | } 85 | }) 86 | 87 | test('teardown', assert => teardown().then(assert.end)) 88 | -------------------------------------------------------------------------------- /test/integrate-pummel-leak-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const childProcess = require('child_process') 4 | const spawn = childProcess.spawn 5 | const pg = require('pg') 6 | const { promisify } = require('utils') 7 | 8 | const domain = require('../lib/domain.js') 9 | const db = require('../db-session.js') 10 | 11 | const TEST_DB_NAME = process.env.TEST_DB_NAME || 'pg_db_session_test' 12 | const IS_MAIN = !process.env.TAP 13 | 14 | if (process.env.IS_CHILD) { 15 | runChild() 16 | } else { 17 | const test = require('tap').test 18 | test('setup', assert => setup().then(assert.end)) 19 | test('pummel: make sure we are not leaking memory', runParent) 20 | test('teardown', assert => teardown().then(assert.end)) 21 | } 22 | 23 | function setup () { 24 | return teardown().then(() => new Promise(resolve => { 25 | spawn('createdb', [TEST_DB_NAME]).on('exit', resolve) 26 | })) 27 | } 28 | 29 | function teardown () { 30 | return new Promise(resolve => { 31 | spawn('dropdb', [TEST_DB_NAME]).on('exit', resolve) 32 | }) 33 | } 34 | 35 | function runParent (assert) { 36 | const child = spawn(process.execPath, [ 37 | '--expose_gc', 38 | '--max_old_space_size=32', 39 | __filename 40 | ], { 41 | env: Object.assign({}, process.env, { 42 | IS_CHILD: 1, 43 | TEST_DB_NAME, 44 | BLUEBIRD_DEBUG: 0 45 | }) 46 | }) 47 | 48 | if (IS_MAIN) { 49 | child.stderr.pipe(process.stderr) 50 | } 51 | const gotSignal = new Promise(resolve => { 52 | child.once('close', code => { 53 | resolve(code) 54 | }) 55 | }) 56 | 57 | const checkCode = gotSignal.then(code => { 58 | assert.equal(code, 0) 59 | }) 60 | 61 | return checkCode 62 | .catch(err => assert.fail(err)) 63 | .finally(assert.end) 64 | } 65 | 66 | function runChild () { 67 | // if we leak domains, given a 32mb old space size we should crash in advance 68 | // of this number 69 | const ITERATIONS = 70000 70 | var count = 0 71 | var pending = 20 72 | 73 | var done = null 74 | const doRun = new Promise((resolve, reject) => { 75 | done = resolve 76 | }) 77 | 78 | function iter () { 79 | if (count % 1000 === 0) { 80 | process._rawDebug(count, process.memoryUsage()) 81 | } 82 | if (++count < ITERATIONS) { 83 | return run().then(iter) 84 | } 85 | return !--pending && done() 86 | } 87 | 88 | for (var i = 0; i < 20; ++i) { 89 | iter() 90 | } 91 | 92 | return doRun 93 | .finally(() => pg.end()) 94 | 95 | function run () { 96 | const domain1 = domain.create() 97 | 98 | db.install(domain1, getConnection, {maxConcurrency: 0}) 99 | 100 | return domain1.run(() => runOperation()).then(() => { 101 | domain1.exit() 102 | }) 103 | } 104 | 105 | async function runOperation () { 106 | const { connection, release } = await db.getConnection() 107 | const query = promisify(connection.query) 108 | const data = await query('SELECT 1') 109 | await release() 110 | return data 111 | } 112 | 113 | function getConnection () { 114 | return new Promise((resolve, reject) => { 115 | pg.connect(`postgres://localhost/${TEST_DB_NAME}`, onconn) 116 | 117 | function onconn (err, connection, release) { 118 | err ? reject(err) : resolve({connection, release}) 119 | } 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /test/integrate-server-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tap').test 4 | const http = require('http') 5 | 6 | const domain = require('../lib/domain.js') 7 | const db = require('../db-session.js') 8 | 9 | function runOperation () { 10 | return db.getConnection().then(pair => { 11 | pair.release() 12 | return 'ok!' 13 | }) 14 | } 15 | 16 | test('test requests do not leak domains into requester', assert => { 17 | process.domain.exit() 18 | const server = http.createServer((req, res) => { 19 | const domain1 = domain.create() 20 | db.install(domain1, getConnection, {maxConcurrency: 0}) 21 | 22 | domain1.add(req) 23 | domain1.add(res) 24 | 25 | const result = domain1.run(() => { 26 | return runOperation() 27 | }) 28 | 29 | const removed = result.then(data => { 30 | domain1.remove(req) 31 | domain1.remove(res) 32 | }) 33 | 34 | return removed.then(() => { 35 | return result 36 | }).then(data => { 37 | res.end(data) 38 | }) 39 | }) 40 | 41 | server.listen(60808, () => { 42 | http.get('http://localhost:60808', res => { 43 | assert.ok(!process.domain) 44 | var acc = [] 45 | res.on('data', data => { 46 | assert.ok(!process.domain) 47 | acc.push(data) 48 | }) 49 | res.on('end', () => { 50 | assert.ok(!process.domain) 51 | server.close(() => { 52 | assert.end() 53 | }) 54 | }) 55 | }) 56 | }) 57 | 58 | function getConnection () { 59 | return { 60 | connection: {query (sql, ready) { 61 | return ready() 62 | }}, 63 | release () { 64 | } 65 | } 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /utils/delay.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) 4 | 5 | module.exports = delay --------------------------------------------------------------------------------