We’re seeing queries in production occasionally return a different query’s result set. The exception shows our SQL, but the result has another statement’s columns. When the two statements happen to have the same columns, nothing errors and the wrong rows are used silently. That’s a data-integrity and data-exposure problem for us.
We’ve reproduced it locally and think we know the cause.
Environment
- 6.2.8.20 (local, where we reproduced it)
- Java 17, PostgreSQL JDBC driver, no external connection pooler
- Server-level datasources, default pool settings,
validate=false
What we think is happening
-
A request hits its request timeout while a query is waiting on the database.
-
Lucee interrupts the thread and then, on the forced pass, calls
Thread.stop()(SystemUtil.stop, ~line 1412). If the database response has already arrived, the full result is left unread on the JDBC connection. -
Since LDEV-5966 (commit
3417970d4, first in 6.2.5.6),DatasourceManagerImpl.releaseConnectionalways callsrelease(), which returns the connection to the pool. It used to callIOUtil.closeEL(dc)whengetTimeoutStackTrace() != null:- if (pc != null && ((PageContextImpl) pc).getTimeoutStackTrace() != null) { - IOUtil.closeEL(dc); - } - else { - ((DatasourceConnectionPro) dc).release(); - } + ((DatasourceConnectionPro) dc).release(); -
With
validate=false,validateObjecton borrow only checksisClosed(), which is stillfalse. The next request to borrow that connection reads the stale result as the answer to its own query.
Repro
Two pages, same datasource, run one after the other (not concurrently):
a.cfm: times out after the database has sent its result:
<cfsetting requesttimeout="3">
<cfscript>
q = queryExecute(
"select 'AAAA' as a_marker, g as a_id from (select pg_sleep(8)) s, generate_series(1, 3) g",
[], { datasource: "mydsn" }
);
writeOutput("A finished without a timeout");
</cfscript>
b.cfm: runs small queries and checks each result is its own:
<cfscript>
bad = 0;
for (i = 1; i <= 50; i++) {
q = queryExecute("select 'B' as b_marker, #i# as b_i", [], { datasource: "mydsn" });
if (q.columnList != "B_MARKER,B_I" || q.b_i[1] != i) {
bad++;
writeOutput("query #i# got columns [#q.columnList#]<br>");
}
}
writeOutput("mismatches: #bad#");
</cfscript>
Request a.cfm (it times out), then request b.cfm.
Results
| Scenario | Swapped results in b.cfm |
|---|---|
Same datasource, validate=false |
13 of 18 runs: b.cfm’s first query returned A_MARKER,A_ID |
| A and B on different datasources | 0 of 24 |
Same datasource, validate=true |
0 of 18 |
Other things we saw:
- If the timeout lands while rows are still streaming (a slow query that sends rows gradually), the next borrower gets a socket/I-O error instead of a swapped result.
- With
validate=truethe stale connection is still reused (same Postgres backend pid). TheisValid()round trip on borrow seems to consume the leftover result, sovalidate=trueworks around it but doesn’t really fix it. It also adds one round trip per borrow.
Questions
- Does this match your understanding? Should a connection whose thread was stopped or interrupted mid-query be closed (or at least invalidated in the pool) rather than released? The LDEV-5966 change fixed pool accounting, but it seems to have removed the only guard against reusing a connection in an unknown protocol state. An
invalidateObjecton the pool would keep the accounting correct and still discard the connection. - Is
validate=truethe recommended mitigation in the meantime, or is there a better one? - On Java 19+
Thread.stop()throwsUnsupportedOperationExceptionand only the interrupt happens. Is the same risk expected there, for example a driver that abandons a read on interrupt?