Skip to content

Commit e3cb40c

Browse files
committed
Fix pg-query-stream
There were some subtle behaviors with the stream being implemented incorrectly & not working as expected with async iteration. I've modified the code based on #2050 and comments in #2035 to have better test coverage of async iterables and update the internals significantly to more closely match the readable stream interface. Note: this is a __breaking__ (semver major) change to this package as the close event behavior is changed slightly, and `highWaterMark` is no longer supported. It shouldn't impact most usage, but breaking regardless.
1 parent 0b87d49 commit e3cb40c

File tree

4 files changed

+106
-43
lines changed

4 files changed

+106
-43
lines changed

packages/pg-query-stream/index.js

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
'use strict'
2-
var Cursor = require('pg-cursor')
3-
var Readable = require('stream').Readable
1+
const { Readable } = require('stream')
2+
const Cursor = require('pg-cursor')
43

54
class PgQueryStream extends Readable {
6-
constructor (text, values, options) {
7-
super(Object.assign({ objectMode: true }, options))
8-
this.cursor = new Cursor(text, values, options)
5+
constructor(text, values, config = {}) {
6+
const { batchSize = 100 } = config;
7+
// https://nodejs.org/api/stream.html#stream_new_stream_readable_options
8+
super({ objectMode: true, emitClose: true, autoDestroy: true, highWaterMark: batchSize })
9+
this.cursor = new Cursor(text, values, config)
10+
911
this._reading = false
10-
this._closed = false
11-
this.batchSize = (options || {}).batchSize || 100
12+
this._callbacks = []
13+
this._err = undefined;
1214

1315
// delegate Submittable callbacks to cursor
1416
this.handleRowDescription = this.cursor.handleRowDescription.bind(this.cursor)
@@ -19,40 +21,51 @@ class PgQueryStream extends Readable {
1921
this.handleError = this.cursor.handleError.bind(this.cursor)
2022
}
2123

22-
submit (connection) {
24+
submit(connection) {
2325
this.cursor.submit(connection)
2426
}
2527

26-
close (callback) {
27-
this._closed = true
28-
const cb = callback || (() => this.emit('close'))
29-
this.cursor.close(cb)
28+
close(callback) {
29+
if (this.destroyed) {
30+
if (callback) setImmediate(callback)
31+
} else {
32+
if (callback) this.once('close', callback)
33+
this.destroy()
34+
}
35+
}
36+
37+
_close() {
38+
this.cursor.close((err) => {
39+
let cb
40+
while ((cb = this._callbacks.pop())) cb(err || this._err)
41+
})
3042
}
3143

32-
_read (size) {
33-
if (this._reading || this._closed) {
34-
return false
44+
_destroy(_err, callback) {
45+
this._err = _err;
46+
this._callbacks.push(callback)
47+
if (!this._reading) {
48+
this._close()
3549
}
50+
}
51+
52+
// https://nodejs.org/api/stream.html#stream_readable_read_size_1
53+
_read(size) {
54+
// Prevent _destroy() from closing while reading
3655
this._reading = true
37-
const readAmount = Math.max(size, this.batchSize)
38-
this.cursor.read(readAmount, (err, rows) => {
39-
if (this._closed) {
40-
return
41-
}
42-
if (err) {
43-
return this.emit('error', err)
44-
}
45-
// if we get a 0 length array we've read to the end of the cursor
46-
if (!rows.length) {
47-
this._closed = true
48-
setImmediate(() => this.emit('close'))
49-
return this.push(null)
50-
}
5156

52-
// push each row into the stream
57+
this.cursor.read(size, (err, rows, result) => {
5358
this._reading = false
54-
for (var i = 0; i < rows.length; i++) {
55-
this.push(rows[i])
59+
60+
if (this.destroyed) {
61+
// Destroyed while reading?
62+
this._close()
63+
} else if (err) {
64+
// https://nodejs.org/api/stream.html#stream_errors_while_reading
65+
this.destroy(err)
66+
} else {
67+
for (const row of rows) this.push(row)
68+
if (rows.length < size) this.push(null)
5669
}
5770
})
5871
}

packages/pg-query-stream/test/async-iterator.es6

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,59 @@ describe('Async iterator', () => {
5454
assert.equal(allRows.length, 603)
5555
await pool.end()
5656
})
57+
58+
it('can break out of iteration early', async () => {
59+
const pool = new pg.Pool({ max: 1 })
60+
const client = await pool.connect()
61+
const rows = []
62+
for await (const row of client.query(new QueryStream(queryText, []))) {
63+
rows.push(row)
64+
break;
65+
}
66+
for await (const row of client.query(new QueryStream(queryText, []))) {
67+
rows.push(row)
68+
break;
69+
}
70+
for await (const row of client.query(new QueryStream(queryText, []))) {
71+
rows.push(row)
72+
break;
73+
}
74+
assert.strictEqual(rows.length, 3)
75+
client.release()
76+
await pool.end()
77+
})
78+
79+
it('only returns rows on first iteration', async () => {
80+
const pool = new pg.Pool({ max: 1 })
81+
const client = await pool.connect()
82+
const rows = []
83+
const stream = client.query(new QueryStream(queryText, []))
84+
for await (const row of stream) {
85+
rows.push(row)
86+
break;
87+
}
88+
for await (const row of stream) {
89+
rows.push(row)
90+
}
91+
for await (const row of stream) {
92+
rows.push(row)
93+
}
94+
assert.strictEqual(rows.length, 1)
95+
client.release()
96+
await pool.end()
97+
})
98+
99+
it('can read with delays', async () => {
100+
const pool = new pg.Pool({ max: 1 })
101+
const client = await pool.connect()
102+
const rows = []
103+
const stream = client.query(new QueryStream(queryText, [], { batchSize: 1 }))
104+
for await (const row of stream) {
105+
rows.push(row)
106+
await new Promise((resolve) => setTimeout(resolve, 1))
107+
}
108+
assert.strictEqual(rows.length, 201)
109+
client.release()
110+
await pool.end()
111+
})
57112
})

packages/pg-query-stream/test/close.js

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@ var helper = require('./helper')
66

77
helper('close', function (client) {
88
it('emits close', function (done) {
9-
var stream = new QueryStream('SELECT * FROM generate_series(0, $1) num', [3], {batchSize: 2, highWaterMark: 2})
9+
var stream = new QueryStream('SELECT * FROM generate_series(0, $1) num', [3], { batchSize: 2, highWaterMark: 2 })
1010
var query = client.query(stream)
11-
query.pipe(concat(function () {}))
11+
query.pipe(concat(function () { }))
1212
query.on('close', done)
1313
})
1414
})
1515

1616
helper('early close', function (client) {
1717
it('can be closed early', function (done) {
18-
var stream = new QueryStream('SELECT * FROM generate_series(0, $1) num', [20000], {batchSize: 2, highWaterMark: 2})
18+
var stream = new QueryStream('SELECT * FROM generate_series(0, $1) num', [20000], { batchSize: 2, highWaterMark: 2 })
1919
var query = client.query(stream)
2020
var readCount = 0
2121
query.on('readable', function () {
@@ -34,7 +34,7 @@ helper('early close', function (client) {
3434

3535
helper('close callback', function (client) {
3636
it('notifies an optional callback when the conneciton is closed', function (done) {
37-
var stream = new QueryStream('SELECT * FROM generate_series(0, $1) num', [10], {batchSize: 2, highWaterMark: 2})
37+
var stream = new QueryStream('SELECT * FROM generate_series(0, $1) num', [10], { batchSize: 2, highWaterMark: 2 })
3838
var query = client.query(stream)
3939
query.once('readable', function () { // only reading once
4040
query.read()
@@ -45,8 +45,5 @@ helper('close callback', function (client) {
4545
done()
4646
})
4747
})
48-
query.on('close', function () {
49-
assert(false, 'close event should not fire') // no close event because we did not read to the end of the stream.
50-
})
5148
})
5249
})

packages/pg-query-stream/test/config.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ var assert = require('assert')
22
var QueryStream = require('../')
33

44
var stream = new QueryStream('SELECT NOW()', [], {
5-
highWaterMark: 999,
65
batchSize: 88
76
})
87

9-
assert.equal(stream._readableState.highWaterMark, 999)
10-
assert.equal(stream.batchSize, 88)
8+
assert.equal(stream._readableState.highWaterMark, 88)

0 commit comments

Comments
 (0)