Skip to content

fix(stream): release the JDBC result stream when it is destroyed - #105

Open
LittleGnome wants to merge 1 commit into
tryggingamidstodin:masterfrom
LittleGnome:fix/jdbc-result-stream-lifecycle
Open

fix(stream): release the JDBC result stream when it is destroyed#105
LittleGnome wants to merge 1 commit into
tryggingamidstodin:masterfrom
LittleGnome:fix/jdbc-result-stream-lifecycle

Conversation

@LittleGnome

Copy link
Copy Markdown

Release the JDBC result stream when it is destroyed

Branch: fix/jdbc-result-stream-lifecycle

Three independent problems in ts-src/lib/jdbcstream.ts. They are grouped
because they are all about what happens to a result stream that does not end
the way the happy path assumes, and they overlap in the same twenty lines.

1. A destroyed stream leaks its connection

JdbcStream only closes the Java-side ResultStream from inside _read, on
the tick after close() has set the _closed flag. A consumer that stops
early never gets there, because no further _read arrives.

for await (const row of stream) {
  if (row.ID === wanted) break // connection never comes back
}

The same happens on stream.destroy() and when a later stage of a pipe()
chain errors. ResultStream.close() is what returns the connection to the
pool, so each occurrence permanently removes one connection. With
AS400JDBCConnectionPool there is nothing that reclaims it afterwards, and
once the pool is drained unrelated queries start blocking.

Fixed by implementing _destroy, which also covers being destroyed before
the queryAsStream promise has resolved.

Why the extra flag

ResultStream.close() returns its connection unconditionally, and
ResultStream.read() already calls close() at end of data. So a naive
_destroy would close a second time on the normal path — Node calls
_destroy after 'end' because autoDestroy defaults to true — and hand the
same connection to two callers.

A _jdbcStreamClosed flag now tracks whether the Java side still owns the
connection, and _destroy does nothing once it does not.

Follow-up worth considering: making ResultStream.close() idempotent on
the Java side would make this unnecessary and would also protect the one case
this patch deliberately leaves alone — a failure inside read()'s init
block, where the Java code has not closed but this patch assumes it has,
because double-returning a connection is worse than the leak that already
exists there today. Happy to send that as a separate PR; it needs a jar
rebuild, so it did not belong in this one.

2. An abandoned stream can terminate the process

_jdbcStreamPromise gets its rejection handler in _read. A stream that is
created and never read — the normal outcome when the query itself fails and
the caller never subscribes — leaves the rejection unhandled. On every Node
version this package supports (engines: >=16), the default policy for that
is to terminate the process.

The constructor now parks a no-op handler on the promise. The real handler in
_read is unchanged, so a consumer that does read still gets the error.

3. A close failure crashes consumers that already got their rows

A failing close() was reported with emit('error'). At that point every row
has been delivered and 'end' may already have fired, so consumers have
typically stopped listening for errors — and an 'error' with no listener is
an uncaught exception. A connection that could not be released turned into a
process-level failure.

It now goes to the Logger the library already has. baseConnection and
connection pass their logger in at the three construction sites; the default
stays the existing no-op logger, so nothing is printed unless a logger is
configured.

Tests

ts-src/unit-test/jdbcstream-spec.ts, 8 cases, using a fake Java stream that
counts close() calls. No JVM and no IBM i, so they run in CI.

Against the current implementation, 5 of the 8 fail:

✔ should read every chunk and end
✔ should not close the java stream again after it ended by itself
1) should close the java stream when a consumer destroys it early
2) should close the java stream when destroyed with an error
3) should close the java stream when destroyed before the promise resolved
4) should log rather than emit an error when close fails
5) should not leave an unhandled rejection when the stream is never read
✔ should still emit the rejection to a reading consumer

The three that already pass are there on purpose: they guard the behaviour
this change could plausibly break, in particular the double-close.

Verification

npm run build && npm test       -> 40 passing
npm run test-cjs                -> 40 passing
npm run lint                    -> clean
npm run format-verify           -> clean

No Java change, no jar rebuild, no new dependency, no public API change.

JdbcStream only closes the Java-side ResultStream from within _read, on
the tick after close() has flipped the _closed flag. A consumer that
stops early never triggers that path, because no further _read arrives:
the result set and the pooled connection behind it are then held until
the JVM exits.

This happens in ordinary use -- breaking out of a for-await loop, an
error thrown further down a pipe() chain, or an explicit destroy() -- and
each occurrence permanently removes one connection from the pool.

Implements _destroy so the Java stream is closed whenever the readable is
torn down, including when the teardown happens before the queryAsStream
promise has even resolved.

ResultStream.close() returns its connection to the pool unconditionally
and read() already closes at end of data, so closing again from _destroy
would hand the same connection out twice. A _jdbcStreamClosed flag now
tracks whether the Java side still owns the connection, and _destroy is a
no-op once it does not. Making ResultStream.close() idempotent on the
Java side would let this be simpler, and is a good follow-up.

Two further failure modes in the same file:

A rejected _jdbcStreamPromise had no handler until a consumer read from
the stream. A stream that was created and then abandoned -- the normal
outcome when the query itself fails -- therefore surfaced as an unhandled
rejection, which terminates the process under Node's default policy on
every supported version. The constructor now parks a no-op handler; the
real handler in _read still reports the failure to a reading consumer.

A close() failure was reported by emitting 'error'. At that point every
row has already been delivered and consumers have usually detached their
error handling, so a failure to release a connection escalated into an
uncaught exception. It is now reported through the logger that the rest
of the library already uses, and the logger is passed in at the three
construction sites.

Adds unit tests covering all three, using a fake Java stream that counts
close() calls. Five of the eight fail against the current implementation;
the other three guard against the double-close this change could
otherwise introduce. No JVM or IBM i is needed, so they run in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant