├── .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 |
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
--------------------------------------------------------------------------------