From 13b1fee798a46d720c460a2eb18da7bd6b297140 Mon Sep 17 00:00:00 2001
From: Julia Yan
Date: Wed, 3 Jun 2026 15:23:00 -0400
Subject: [PATCH 01/10] support for BLOB
---
.../requests/BlockRetrievableRequest.java | 21 +++++++++++++++++++
.../mapepire/requests/PreparedExecute.java | 20 ++++++++++++++++--
2 files changed, 39 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/github/ibm/mapepire/requests/BlockRetrievableRequest.java b/src/main/java/com/github/ibm/mapepire/requests/BlockRetrievableRequest.java
index a16bbd6..89d831f 100644
--- a/src/main/java/com/github/ibm/mapepire/requests/BlockRetrievableRequest.java
+++ b/src/main/java/com/github/ibm/mapepire/requests/BlockRetrievableRequest.java
@@ -1,6 +1,7 @@
package com.github.ibm.mapepire.requests;
import java.sql.*;
+import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
@@ -61,6 +62,16 @@ List
*
- *
{@link #openStream()} will block (up to the TTL) until the spool
- * thread signals the latch.
+ *
The TTL countdown starts when the spool finishes, not when
+ * the token is registered. This ensures the client always gets a full TTL
+ * window to fetch the blob regardless of how long the spool takes. The
+ * sweeper skips entries whose spool is still in progress.
+ *
+ *
{@link #openStream()} will block until the spool thread signals the
+ * latch (no timeout — the spool is driven by JDBC, not the client).
*/
static BlobEntry ofBlobAsync(Blob blob, long declaredLength,
Instant expiresAt, String credentials,
@@ -275,9 +281,13 @@ static BlobEntry ofBlobAsync(Blob blob, long declaredLength,
File tmp = Files.createTempFile("mapepire-blob-", ".tmp").toFile();
tmp.deleteOnExit();
- // Latch starts at 1 — spool thread counts it down when done
+ // Latch starts at 1 — spool thread counts it down when done.
+ // expiresAt is set to FAR_FUTURE while spooling so the sweeper
+ // leaves it alone; the spool thread resets it to now+TTL on completion.
CountDownLatch ready = new CountDownLatch(1);
- BlobEntry entry = new BlobEntry(null, tmp, declaredLength, expiresAt, credentials, ready);
+ BlobEntry entry = new BlobEntry(null, tmp, declaredLength,
+ Instant.MAX /* placeholder — updated by spool thread */,
+ credentials, ready);
Thread spoolThread = new Thread(() -> {
try (FileOutputStream fos = new FileOutputStream(tmp);
@@ -289,13 +299,17 @@ static BlobEntry ofBlobAsync(Blob blob, long declaredLength,
fos.write(buf, 0, read);
total += read;
}
+ // Spool finished — start the TTL clock now
+ entry.expiresAt = Instant.now().plusSeconds(s_ttlSeconds);
Tracer.info("BlobStore: spool complete for token " + tokenForLogging
- + " (" + total + " bytes)");
+ + " (" + total + " bytes), expires=" + entry.expiresAt);
} catch (IOException | SQLException e) {
Tracer.err(e);
entry.m_spoolError = (e instanceof IOException)
? (IOException) e
: new IOException("JDBC Blob read failed: " + e.getMessage(), e);
+ // On failure, expire immediately so the sweeper cleans it up
+ entry.expiresAt = Instant.now();
} finally {
try { blob.free(); } catch (SQLException ignored) {}
ready.countDown();
@@ -322,14 +336,11 @@ static BlobEntry ofBlobAsync(Blob blob, long declaredLength,
*/
public InputStream openStream() throws IOException {
if (m_ready.getCount() > 0) {
- // Still spooling — wait up to the remaining TTL
- long waitSeconds = Math.max(1L,
- expiresAt.getEpochSecond() - Instant.now().getEpochSecond());
+ // Still spooling — wait indefinitely for the JDBC read to finish.
+ // TTL is reset to now+TTL by the spool thread on completion so the
+ // client always gets a full window; no artificial timeout here.
try {
- boolean completed = m_ready.await(waitSeconds, TimeUnit.SECONDS);
- if (!completed) {
- throw new IOException("Blob spool timed out after " + waitSeconds + "s");
- }
+ m_ready.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while waiting for blob spool", e);
From db7f30a8aa694145b8e3d4b65165c3a58967e346 Mon Sep 17 00:00:00 2001
From: Julia Yan
Date: Mon, 8 Jun 2026 14:17:31 -0400
Subject: [PATCH 08/10] Fixes for issues with syncing
---
.../github/ibm/mapepire/http/BlobStore.java | 157 ++++++++++++-----
.../requests/BlockRetrievableRequest.java | 160 +++++++++++++-----
.../ibm/mapepire/requests/PrepareSql.java | 15 ++
3 files changed, 244 insertions(+), 88 deletions(-)
diff --git a/src/main/java/com/github/ibm/mapepire/http/BlobStore.java b/src/main/java/com/github/ibm/mapepire/http/BlobStore.java
index e96aaa5..facd297 100644
--- a/src/main/java/com/github/ibm/mapepire/http/BlobStore.java
+++ b/src/main/java/com/github/ibm/mapepire/http/BlobStore.java
@@ -145,6 +145,32 @@ public String store(Blob blob, long length, String credentials) throws IOExcepti
return token;
}
+ /**
+ * Like {@link #store(Blob, long, String)} but returns the {@link BlobEntry} directly
+ * so the caller can track the spool latch and defer ResultSet close appropriately.
+ * The {@link BlobEntry#token} field is set before returning.
+ */
+ public BlobEntry storeAndReturnEntry(Blob blob, long length, String credentials) throws IOException {
+ String tok = UUID.randomUUID().toString();
+ Instant expiresAt = Instant.now().plusSeconds(s_ttlSeconds);
+
+ BlobEntry entry;
+ if (length <= MEMORY_THRESHOLD_BYTES) {
+ try {
+ byte[] bytes = readAllBytes(blob.getBinaryStream());
+ blob.free();
+ entry = BlobEntry.ofBytes(bytes, expiresAt, credentials);
+ } catch (SQLException e) {
+ throw new IOException("Failed to read BLOB data", e);
+ }
+ } else {
+ entry = BlobEntry.ofBlobAsync(blob, length, expiresAt, credentials, tok);
+ }
+ entry.token = tok;
+ m_entries.put(tok, entry);
+ return entry;
+ }
+
// -------------------------------------------------------------------------
// Retrieving blobs
// -------------------------------------------------------------------------
@@ -214,36 +240,76 @@ public static class BlobEntry {
// volatile so the spool thread can push expiresAt forward once done
public volatile Instant expiresAt;
public final String credentials; // Base64 "user:pass"
+ /** The UUID token string (without "/blob/" prefix). Set by BlobStore.storeAndReturnEntry(). */
+ public String token;
/**
- * Latch that is counted down to zero when the backing data is fully
- * available (either already at construction for in-memory entries, or
- * when the background spool thread finishes for large blobs).
+ * Latch counted down to zero when the backing data is fully available
+ * (immediately for in-memory/already-spooled entries; when the background
+ * spool thread finishes writing for large blobs).
*/
private final CountDownLatch m_ready;
+ /**
+ * Latch counted down to zero as soon as the spool thread has successfully
+ * opened the JDBC {@link InputStream} — i.e. the JDBC {@link Blob} is no
+ * longer needed and the caller's {@link java.sql.ResultSet} may safely be
+ * closed. Already at 0 for entries that do not use async spooling.
+ */
+ private final CountDownLatch m_spoolStreamOpen;
+
/**
* Non-null if the background spool thread encountered an error.
* Checked by {@link #openStream()} after the latch releases.
*/
private volatile IOException m_spoolError;
+ /** True if this entry uses an async background spool thread. */
+ private final boolean m_isAsyncSpool;
+
private BlobEntry(byte[] bytes, File file, long size, Instant expiresAt,
- String credentials, CountDownLatch ready) {
- this.m_bytes = bytes;
- this.m_file = file;
- this.size = size;
- this.expiresAt = expiresAt;
- this.credentials = credentials;
- this.m_ready = ready;
+ String credentials, CountDownLatch ready, CountDownLatch spoolStreamOpen,
+ boolean isAsyncSpool) {
+ this.m_bytes = bytes;
+ this.m_file = file;
+ this.size = size;
+ this.expiresAt = expiresAt;
+ this.credentials = credentials;
+ this.m_ready = ready;
+ this.m_spoolStreamOpen = spoolStreamOpen;
+ this.m_isAsyncSpool = isAsyncSpool;
+ }
+
+ /**
+ * Returns true if this entry uses a background spool thread (large blob).
+ * False for small blobs stored synchronously in memory.
+ */
+ public boolean isAsyncSpool() {
+ return m_isAsyncSpool;
+ }
+
+ /**
+ * Block until the spool thread has opened the JDBC stream (so the
+ * ResultSet can be safely closed by the caller). Returns immediately
+ * for non-async entries.
+ */
+ public void awaitSpoolStreamOpen() throws InterruptedException {
+ m_spoolStreamOpen.await();
+ }
+
+ /**
+ * Block until the spool thread has finished writing all bytes to disk.
+ * Returns immediately for in-memory / already-complete entries.
+ */
+ public void awaitReady() throws InterruptedException {
+ m_ready.await();
}
// -- factories --------------------------------------------------------
static BlobEntry ofBytes(byte[] bytes, Instant expiresAt, String credentials) {
- // Already ready — latch starts at 0
- CountDownLatch ready = new CountDownLatch(0);
- return new BlobEntry(bytes, null, bytes.length, expiresAt, credentials, ready);
+ CountDownLatch zero = new CountDownLatch(0);
+ return new BlobEntry(bytes, null, bytes.length, expiresAt, credentials, zero, zero, false);
}
static BlobEntry ofFile(byte[] bytes, Instant expiresAt, String credentials) throws IOException {
@@ -252,28 +318,20 @@ static BlobEntry ofFile(byte[] bytes, Instant expiresAt, String credentials) thr
try (FileOutputStream fos = new FileOutputStream(tmp)) {
fos.write(bytes);
}
- // Already ready — latch starts at 0
- CountDownLatch ready = new CountDownLatch(0);
- return new BlobEntry(null, tmp, bytes.length, expiresAt, credentials, ready);
+ CountDownLatch zero = new CountDownLatch(0);
+ return new BlobEntry(null, tmp, bytes.length, expiresAt, credentials, zero, zero, false);
}
/**
- * Create an entry whose data is spooled to a temp file in a background
- * thread from a live JDBC {@link Blob}. The entry is returned immediately
- * so the caller can register the token and send the WebSocket response
- * without waiting.
- *
- *
The {@link Blob} stream is opened inside the thread (not before),
- * so the JDBC cursor is free to advance as soon as this method returns.
- * {@link Blob#free()} is called by the thread in its {@code finally} block.
+ * Opens {@link Blob#getBinaryStream()} on the calling thread before
+ * returning, then spools the bytes to a temp file in a background thread.
*
- *
The TTL countdown starts when the spool finishes, not when
- * the token is registered. This ensures the client always gets a full TTL
- * window to fetch the blob regardless of how long the spool takes. The
- * sweeper skips entries whose spool is still in progress.
- *
- *
{@link #openStream()} will block until the spool thread signals the
- * latch (no timeout — the spool is driven by JDBC, not the client).
+ *
The stream must be opened here — before the caller calls
+ * {@link java.sql.ResultSet#next()} to advance past this row — because the
+ * AS400 JDBC driver invalidates {@link Blob} objects (throwing
+ * {@code [PWS0007] Operation result set not found}) as soon as the cursor
+ * moves off the row that produced them, regardless of whether the
+ * {@link java.sql.ResultSet} is still open.
*/
static BlobEntry ofBlobAsync(Blob blob, long declaredLength,
Instant expiresAt, String credentials,
@@ -281,17 +339,28 @@ static BlobEntry ofBlobAsync(Blob blob, long declaredLength,
File tmp = Files.createTempFile("mapepire-blob-", ".tmp").toFile();
tmp.deleteOnExit();
- // Latch starts at 1 — spool thread counts it down when done.
- // expiresAt is set to FAR_FUTURE while spooling so the sweeper
- // leaves it alone; the spool thread resets it to now+TTL on completion.
- CountDownLatch ready = new CountDownLatch(1);
+ // Open the stream NOW on the calling thread, before _rs.next() is called.
+ final InputStream blobStream;
+ try {
+ blobStream = blob.getBinaryStream();
+ } catch (SQLException e) {
+ try { blob.free(); } catch (SQLException ignored) {}
+ throw new IOException("Failed to open BLOB stream: " + e.getMessage(), e);
+ }
+
+ // Both latches start at 1.
+ // spoolStreamOpen: already counted down (stream is open) — kept at 0 so
+ // isAsyncSpool() still works and awaitSpoolStreamOpen() returns immediately.
+ // ready: counted down when all bytes are written to disk.
+ CountDownLatch spoolStreamOpen = new CountDownLatch(0); // stream already open
+ CountDownLatch ready = new CountDownLatch(1);
BlobEntry entry = new BlobEntry(null, tmp, declaredLength,
- Instant.MAX /* placeholder — updated by spool thread */,
- credentials, ready);
+ Instant.MAX /* updated by spool thread on completion */,
+ credentials, ready, spoolStreamOpen, true);
Thread spoolThread = new Thread(() -> {
try (FileOutputStream fos = new FileOutputStream(tmp);
- InputStream in = blob.getBinaryStream()) {
+ InputStream in = blobStream) {
byte[] buf = new byte[65536];
int read;
long total = 0;
@@ -299,16 +368,10 @@ static BlobEntry ofBlobAsync(Blob blob, long declaredLength,
fos.write(buf, 0, read);
total += read;
}
- // Spool finished — start the TTL clock now
+ fos.flush();
entry.expiresAt = Instant.now().plusSeconds(s_ttlSeconds);
- Tracer.info("BlobStore: spool complete for token " + tokenForLogging
- + " (" + total + " bytes), expires=" + entry.expiresAt);
- } catch (IOException | SQLException e) {
- Tracer.err(e);
- entry.m_spoolError = (e instanceof IOException)
- ? (IOException) e
- : new IOException("JDBC Blob read failed: " + e.getMessage(), e);
- // On failure, expire immediately so the sweeper cleans it up
+ } catch (IOException e) {
+ entry.m_spoolError = e;
entry.expiresAt = Instant.now();
} finally {
try { blob.free(); } catch (SQLException ignored) {}
diff --git a/src/main/java/com/github/ibm/mapepire/requests/BlockRetrievableRequest.java b/src/main/java/com/github/ibm/mapepire/requests/BlockRetrievableRequest.java
index d1736dc..6096b6b 100644
--- a/src/main/java/com/github/ibm/mapepire/requests/BlockRetrievableRequest.java
+++ b/src/main/java/com/github/ibm/mapepire/requests/BlockRetrievableRequest.java
@@ -24,6 +24,9 @@ public abstract class BlockRetrievableRequest extends ClientRequest {
protected ResultSet m_rs = null;
protected final boolean m_isTerseData;
+ /** Holds a deferred ResultSet close (with pending spool entries) until after the WS reply is sent. */
+ private DataBlockFetchResult m_deferredFetchResult = null;
+
protected BlockRetrievableRequest(DataStreamProcessor _io, SystemConnection _conn, JsonObject _reqObj) {
super(_io, _conn, _reqObj);
m_isTerseData = getRequestFieldBoolean("terse", false);
@@ -35,6 +38,10 @@ List getNextDataBlock(final int _numRows) throws SQLException {
}
DataBlockFetchResult result = getNextDataBlock(m_rs, _numRows, m_isTerseData, getSystemConnection());
m_isDone = result.isDone();
+ // Stash deferred close info so processAfterReplySent() can close it safely.
+ if (result.m_deferredRs != null) {
+ m_deferredFetchResult = result;
+ }
return result.m_data;
}
@@ -70,8 +77,20 @@ List getOutputParms(PreparedStatement _stmt) throws SQLException {
jsonValue = value;
} else if (value instanceof Blob) {
Blob blob = (Blob) value;
- // serializeBlob owns blob.free() — do NOT call it here
- jsonValue = serializeBlob(blob, blob.length(), conn);
+ if (MapepireServer.isSingleMode()) {
+ try {
+ byte[] bytes = readAllBytes(blob.getBinaryStream());
+ blob.free();
+ jsonValue = Base64.getEncoder().encodeToString(bytes);
+ } catch (Exception e) {
+ Tracer.err(e);
+ jsonValue = null;
+ }
+ } else {
+ DataBlockFetchResult dummy = new DataBlockFetchResult();
+ BlobStore.BlobEntry bentry = serializeBlob(blob, blob.length(), conn, dummy);
+ jsonValue = bentry != null ? blobEntryToRef(bentry, blob.length()) : null;
+ }
} else if (value instanceof Clob) {
Clob clob = (Clob) value;
jsonValue = clob.getSubString(1, (int) clob.length());
@@ -93,6 +112,13 @@ protected static class DataBlockFetchResult {
private final List m_data = new LinkedList();
private boolean m_isDone = false;
+ /** ResultSet (and optional statement) to close after spool streams open. */
+ ResultSet m_deferredRs = null;
+ boolean m_deferredCloseStatement = false;
+
+ /** BlobEntries from async spools — must call awaitSpoolStreamOpen() before closing RS. */
+ final List m_pendingSpools = new LinkedList<>();
+
private DataBlockFetchResult setDone(final boolean _b) {
m_isDone = _b;
return this;
@@ -109,6 +135,33 @@ private void add(final Object _o) {
public Object getData() {
return m_data;
}
+
+ /**
+ * Wait for all async spool threads to finish writing to disk, then close
+ * the deferred ResultSet/Statement. Safe to call multiple times.
+ *
+ *
We must wait for the full spool (not just stream-open) because the
+ * AS400 JDBC driver may close the blob's {@link InputStream} when the
+ * {@link ResultSet} or {@link Statement} is closed, cutting off a
+ * mid-read spool thread.
+ */
+ void closeDeferredResultSet() {
+ if (m_deferredRs == null) return;
+ // Wait for every spool thread to finish writing before closing the RS.
+ for (BlobStore.BlobEntry e : m_pendingSpools) {
+ try { e.awaitReady(); } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ try {
+ if (m_deferredCloseStatement) {
+ m_deferredRs.getStatement().close();
+ } else {
+ m_deferredRs.close();
+ }
+ } catch (SQLException ignored) {}
+ m_deferredRs = null;
+ }
}
protected static DataBlockFetchResult getNextDataBlock(final ResultSet _rs, final int _numRows,
@@ -125,11 +178,22 @@ protected static DataBlockFetchResult getNextDataBlock(final ResultSet _rs, fina
for (int i = 0; i < _numRows; ++i) {
if (!_rs.next()) {
ret.setDone(true);
- Statement s = _rs.getStatement();
- if (s instanceof PreparedStatement) {
- _rs.close();
+ // Defer the RS close only when there are pending async spool threads that
+ // still need the JDBC Blob stream open. For small blobs (no async spool)
+ // close immediately as before to avoid HY010 "Function sequence" errors
+ // if the client sends the next request before processAfterReplySent() runs.
+ // The deferred close is resolved in processAfterReplySent() after the WS
+ // reply is sent, by which point all spool threads have opened their streams.
+ if (!ret.m_pendingSpools.isEmpty()) {
+ ret.m_deferredRs = _rs;
+ ret.m_deferredCloseStatement = !(_rs.getStatement() instanceof PreparedStatement);
} else {
- _rs.getStatement().close();
+ // No pending spools — safe to close now
+ if (_rs.getStatement() instanceof PreparedStatement) {
+ _rs.close();
+ } else {
+ _rs.getStatement().close();
+ }
}
break;
}
@@ -152,10 +216,23 @@ protected static DataBlockFetchResult getNextDataBlock(final ResultSet _rs, fina
cellDataForResponse = cellData;
} else if (cellData instanceof Blob) {
Blob blob = (Blob) cellData;
- // NOTE: do NOT call blob.free() here — serializeBlob owns the lifecycle.
- // For large blobs it hands the Blob to a background spool thread which
- // calls free() itself. For small blobs serializeBlob calls free() inline.
- cellDataForResponse = serializeBlob(blob, blob.length(), _conn);
+ if (MapepireServer.isSingleMode()) {
+ // Single mode — no HTTP server, fall back to inline Base64
+ try {
+ byte[] bytes = readAllBytes(blob.getBinaryStream());
+ blob.free();
+ cellDataForResponse = Base64.getEncoder().encodeToString(bytes);
+ } catch (Exception e) {
+ Tracer.err(e);
+ cellDataForResponse = null;
+ }
+ } else {
+ // Capture length before serializeBlob, which calls blob.free()
+ // for small blobs — calling blob.length() after free() throws HY010.
+ long blobLength = blob.length();
+ BlobStore.BlobEntry entry = serializeBlob(blob, blobLength, _conn, ret);
+ cellDataForResponse = entry != null ? blobEntryToRef(entry, blobLength) : null;
+ }
} else if (cellData instanceof Clob) {
Clob clob = (Clob) cellData;
cellDataForResponse = clob.getSubString(1, (int) clob.length());
@@ -180,38 +257,19 @@ protected static DataBlockFetchResult getNextDataBlock(final ResultSet _rs, fina
// BLOB serialization helpers
// -------------------------------------------------------------------------
- /**
- * Serialize a BLOB value for the JSON response.
- *
- *
In daemon mode: stores in {@link BlobStore} and returns a
- * {@code {blob_url, size}} map. For small blobs the bytes are read
- * synchronously and {@link Blob#free()} is called before returning.
- * For large blobs the {@link Blob} object is handed to a background
- * spool thread in {@link BlobStore} which calls {@link Blob#free()}
- * when the spool completes — the caller must not free it.
- *
- *
In single mode (no HTTP server): falls back to inline Base64,
- * then frees the Blob.
- */
- private static Object serializeBlob(Blob blob, long length, SystemConnection conn) {
- if (MapepireServer.isSingleMode()) {
- // Single mode has no HTTP server — fall back to inline Base64
- try {
- byte[] bytes = readAllBytes(blob.getBinaryStream());
- blob.free();
- return Base64.getEncoder().encodeToString(bytes);
- } catch (Exception e) {
- Tracer.err(e);
- return null;
- }
- }
+ /** Daemon-mode only: stores in {@link BlobStore}, tracks the entry for deferred RS close. */
+ private static BlobStore.BlobEntry serializeBlob(Blob blob, long length,
+ SystemConnection conn,
+ DataBlockFetchResult result) {
try {
- // BlobStore.store(Blob, ...) owns blob.free() from this point on
- String token = BlobStore.getInstance().store(blob, length, conn.getRawCredentials());
- Map ref = new LinkedHashMap<>();
- ref.put("blob_url", "/blob/" + token);
- ref.put("size", length);
- return ref;
+ // BlobStore.storeAndReturnEntry owns blob.free() from this point on
+ BlobStore.BlobEntry entry = BlobStore.getInstance().storeAndReturnEntry(blob, length, conn.getRawCredentials());
+ // Only track entries that have a live async spool thread — small blobs
+ // are stored synchronously and their latch is already at 0.
+ if (entry != null && entry.isAsyncSpool()) {
+ result.m_pendingSpools.add(entry);
+ }
+ return entry;
} catch (IOException e) {
Tracer.err(e);
try { blob.free(); } catch (Exception ignored) {}
@@ -219,6 +277,13 @@ private static Object serializeBlob(Blob blob, long length, SystemConnection con
}
}
+ private static Map blobEntryToRef(BlobStore.BlobEntry entry, long length) {
+ Map ref = new LinkedHashMap<>();
+ ref.put("blob_url", "/blob/" + entry.token);
+ ref.put("size", length);
+ return ref;
+ }
+
private static Object serializeBytes(byte[] bytes, SystemConnection conn) {
if (MapepireServer.isSingleMode()) {
return Base64.getEncoder().encodeToString(bytes);
@@ -249,6 +314,19 @@ public boolean isDone() {
return m_isDone;
}
+ /**
+ * Called by {@link com.github.ibm.mapepire.ClientRequest#run()} after the WebSocket
+ * reply has been sent. Closes any ResultSet that was deferred to allow async blob
+ * spool threads to open their JDBC streams first.
+ */
+ @Override
+ protected void processAfterReplySent() {
+ if (m_deferredFetchResult != null) {
+ m_deferredFetchResult.closeDeferredResultSet();
+ m_deferredFetchResult = null;
+ }
+ }
+
protected Map getResultMetaDataForResponse() throws SQLException {
return getResultMetaDataForResponse(this.m_rs.getMetaData(), getSystemConnection());
}
diff --git a/src/main/java/com/github/ibm/mapepire/requests/PrepareSql.java b/src/main/java/com/github/ibm/mapepire/requests/PrepareSql.java
index 6087772..3682c11 100644
--- a/src/main/java/com/github/ibm/mapepire/requests/PrepareSql.java
+++ b/src/main/java/com/github/ibm/mapepire/requests/PrepareSql.java
@@ -117,4 +117,19 @@ public boolean isDone() {
return true;
}
+ /**
+ * Delegate to the execute task so that any deferred ResultSet close
+ * (needed when a large-blob async spool is in progress) is triggered
+ * after the WebSocket reply is sent. Without this, m_deferredFetchResult
+ * is set on the PreparedExecute instance but processAfterReplySent() would
+ * only run on this PrepareSql instance, silently skipping the deferred close.
+ */
+ @Override
+ protected void processAfterReplySent() {
+ super.processAfterReplySent();
+ if (m_executeTask != null) {
+ m_executeTask.processAfterReplySent();
+ }
+ }
+
}
From 1238c0a14b1489b9389a1e5d043dc8dbdc6e5472 Mon Sep 17 00:00:00 2001
From: Julia Yan
Date: Mon, 8 Jun 2026 14:47:40 -0400
Subject: [PATCH 09/10] Remove testing credentials
---
.../java/com/github/ibm/mapepire/SystemConnection.java | 8 --------
1 file changed, 8 deletions(-)
diff --git a/src/main/java/com/github/ibm/mapepire/SystemConnection.java b/src/main/java/com/github/ibm/mapepire/SystemConnection.java
index 53169fa..fd61168 100644
--- a/src/main/java/com/github/ibm/mapepire/SystemConnection.java
+++ b/src/main/java/com/github/ibm/mapepire/SystemConnection.java
@@ -143,14 +143,6 @@ public synchronized Connection reconnect(final ConnectionMethod _connectionMetho
as400System = new AS400(systemName);
}
- // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
- // !!!!!!!!!!!!!!!!!!!!!!! TESTING !!!!!!!!!!!!!!!!!!!!!!!
- // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
- as400System = new AS400("common1.frankeni.com", "juliayan", "Sushigirl13-".toCharArray());
- // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
- // !!!!!!!!!!!!!!!!!!!!!!! TESTING !!!!!!!!!!!!!!!!!!!!!!!
- // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
-
// Parse JDBC properties into Properties object
Properties jdbcProps = new Properties();
if (StringUtils.isNonEmpty(_jdbcProps)) {
From 98722d5749060d2536afbe59ecedd6cc8a0f3fca Mon Sep 17 00:00:00 2001
From: Hisham Siddique
Date: Wed, 5 Aug 2026 10:20:49 -0400
Subject: [PATCH 10/10] Blob Support Final
Fixed getOutputParms() length bug and tracking bug for large BLOBs
Cleaned up unreachable code
Fixed Content-Length to use actual size
Sanitize log injection for BlobServlet
---
.../java/com/github/ibm/mapepire/Version.java | 4 +-
.../github/ibm/mapepire/http/BlobServlet.java | 10 +-
.../github/ibm/mapepire/http/BlobStore.java | 143 +++++++-----------
.../requests/BlockRetrievableRequest.java | 51 +++++--
.../mapepire/requests/PreparedExecute.java | 8 +
.../ibm/mapepire/requests/RunSqlMore.java | 10 ++
6 files changed, 121 insertions(+), 105 deletions(-)
diff --git a/src/main/java/com/github/ibm/mapepire/Version.java b/src/main/java/com/github/ibm/mapepire/Version.java
index 225954b..3522236 100644
--- a/src/main/java/com/github/ibm/mapepire/Version.java
+++ b/src/main/java/com/github/ibm/mapepire/Version.java
@@ -1,5 +1,5 @@
package com.github.ibm.mapepire;
public class Version {
- static public final String s_compileDateTime = "2024-08-08 00:36:20 (GMT)";
- static public final String s_version = "2.0.0-rc1";
+ static public final String s_compileDateTime = "2026-07-28 14:46:43 (GMT)";
+ static public final String s_version = "2.3.5";
}
\ No newline at end of file
diff --git a/src/main/java/com/github/ibm/mapepire/http/BlobServlet.java b/src/main/java/com/github/ibm/mapepire/http/BlobServlet.java
index 6742f02..261fc5f 100644
--- a/src/main/java/com/github/ibm/mapepire/http/BlobServlet.java
+++ b/src/main/java/com/github/ibm/mapepire/http/BlobServlet.java
@@ -79,8 +79,12 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IO
// ---- Stream bytes ---------------------------------------------------
// Headers are committed only after openStream() succeeds, ensuring the
// status code is always meaningful.
+ // Use actual file size after spool completes — may differ from the JDBC-declared
+ // length for some IBM i LOB types.
+ long actualSize = entry.getActualSize();
resp.setContentType("application/octet-stream");
- resp.setHeader("Content-Length", String.valueOf(entry.size));
+ resp.setHeader("Content-Disposition", "attachment; filename=\"blob\"");
+ resp.setHeader("Content-Length", String.valueOf(actualSize));
resp.setHeader("Cache-Control", "no-store");
try {
@@ -91,7 +95,9 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IO
out.write(buf, 0, read);
}
out.flush();
- Tracer.info("BlobServlet: streamed token " + token + " (" + entry.size + " bytes)");
+ // Sanitise token before logging to prevent log injection.
+ String safeToken = token.replaceAll("[^a-fA-F0-9\\-]", "?");
+ Tracer.info("BlobServlet: streamed token " + safeToken + " (" + actualSize + " bytes)");
} finally {
try { in.close(); } catch (IOException ignored) {}
entry.cleanup();
diff --git a/src/main/java/com/github/ibm/mapepire/http/BlobStore.java b/src/main/java/com/github/ibm/mapepire/http/BlobStore.java
index facd297..8cf96f6 100644
--- a/src/main/java/com/github/ibm/mapepire/http/BlobStore.java
+++ b/src/main/java/com/github/ibm/mapepire/http/BlobStore.java
@@ -50,7 +50,7 @@ public class BlobStore {
});
private BlobStore() {
- // Read TTL from environment on startup; setTtlSeconds() can override later
+ // Read TTL from environment on startup; override at runtime via setTtlSeconds().
String envTtl = System.getenv("BLOB_TOKEN_TTL");
if (envTtl != null && !envTtl.isEmpty()) {
try {
@@ -67,13 +67,18 @@ public static BlobStore getInstance() {
}
// -------------------------------------------------------------------------
- // TTL configuration (read-only at runtime — set via BLOB_TOKEN_TTL env var)
+ // TTL configuration
// -------------------------------------------------------------------------
public static long getTtlSeconds() {
return s_ttlSeconds;
}
+ /** Override the token TTL at runtime (e.g. from a setconfig request). */
+ public static void setTtlSeconds(long ttl) {
+ s_ttlSeconds = ttl;
+ }
+
// -------------------------------------------------------------------------
// Storing blobs
// -------------------------------------------------------------------------
@@ -102,53 +107,18 @@ public String store(byte[] data, String credentials) throws IOException {
}
/**
- * Store a BLOB from a live JDBC {@link Blob} object.
- *
- *
For blobs above {@link #MEMORY_THRESHOLD_BYTES} the spool to disk runs
- * in a background thread so this method returns — and the caller can send the
- * WebSocket response with the {@code blob_url} — immediately, without waiting
- * for all bytes to land on disk. The HTTP GET on {@code /blob/{token}} will
- * block until the spool is complete (or the TTL elapses).
+ * Store a BLOB from a live JDBC {@link Blob} object and return the {@link BlobEntry}
+ * directly so the caller can track the async spool latch and defer closing the JDBC
+ * resource appropriately.
*
- *
The {@link Blob} stream is opened inside the spool thread for
- * large blobs so that the JDBC cursor can advance freely once this method
- * returns. {@link Blob#free()} is called by the spool thread when it finishes.
+ *
For blobs above {@link #MEMORY_THRESHOLD_BYTES} the spool to disk runs in a
+ * background thread so this method returns — and the caller can send the WebSocket
+ * response with the {@code blob_url} — immediately. The HTTP GET on
+ * {@code /blob/{token}} will block until the spool completes.
*
- *
For small blobs the bytes are read synchronously into memory before
- * returning, then {@link Blob#free()} is called immediately.
- */
- public String store(Blob blob, long length, String credentials) throws IOException {
- String token = UUID.randomUUID().toString();
- Instant expiresAt = Instant.now().plusSeconds(s_ttlSeconds);
-
- BlobEntry entry;
- if (length <= MEMORY_THRESHOLD_BYTES) {
- // Small blob — materialise into heap now so the Blob/cursor can be freed immediately
- try {
- byte[] bytes = readAllBytes(blob.getBinaryStream());
- blob.free();
- entry = BlobEntry.ofBytes(bytes, expiresAt, credentials);
- } catch (SQLException e) {
- throw new IOException("Failed to read BLOB data", e);
- }
- m_entries.put(token, entry);
- Tracer.info("BlobStore: stored token " + token + " size=" + length + " expires=" + expiresAt);
- } else {
- // Large blob — register token immediately, spool to disk in background.
- // The Blob object is passed to the spool thread which opens the stream
- // and calls free() itself — the caller must NOT free the Blob after this.
- entry = BlobEntry.ofBlobAsync(blob, length, expiresAt, credentials, token);
- m_entries.put(token, entry);
- Tracer.info("BlobStore: registered token " + token + " size=" + length
- + " expires=" + expiresAt + " (spool in progress)");
- }
- return token;
- }
-
- /**
- * Like {@link #store(Blob, long, String)} but returns the {@link BlobEntry} directly
- * so the caller can track the spool latch and defer ResultSet close appropriately.
- * The {@link BlobEntry#token} field is set before returning.
+ *
The BLOB stream is opened on the calling thread before returning so
+ * that the JDBC cursor can safely advance. {@link Blob#free()} is called by the spool
+ * thread when it finishes (large blobs) or immediately for small blobs.
*/
public BlobEntry storeAndReturnEntry(Blob blob, long length, String credentials) throws IOException {
String tok = UUID.randomUUID().toString();
@@ -166,7 +136,7 @@ public BlobEntry storeAndReturnEntry(Blob blob, long length, String credentials)
} else {
entry = BlobEntry.ofBlobAsync(blob, length, expiresAt, credentials, tok);
}
- entry.token = tok;
+ entry.m_token = tok;
m_entries.put(tok, entry);
return entry;
}
@@ -240,8 +210,13 @@ public static class BlobEntry {
// volatile so the spool thread can push expiresAt forward once done
public volatile Instant expiresAt;
public final String credentials; // Base64 "user:pass"
- /** The UUID token string (without "/blob/" prefix). Set by BlobStore.storeAndReturnEntry(). */
- public String token;
+ /** The UUID token string (without "/blob/" prefix). Set by {@link BlobStore#storeAndReturnEntry}. */
+ private String m_token;
+
+ /** Returns the UUID token string (without "/blob/" prefix). */
+ public String getToken() {
+ return m_token;
+ }
/**
* Latch counted down to zero when the backing data is fully available
@@ -250,14 +225,6 @@ public static class BlobEntry {
*/
private final CountDownLatch m_ready;
- /**
- * Latch counted down to zero as soon as the spool thread has successfully
- * opened the JDBC {@link InputStream} — i.e. the JDBC {@link Blob} is no
- * longer needed and the caller's {@link java.sql.ResultSet} may safely be
- * closed. Already at 0 for entries that do not use async spooling.
- */
- private final CountDownLatch m_spoolStreamOpen;
-
/**
* Non-null if the background spool thread encountered an error.
* Checked by {@link #openStream()} after the latch releases.
@@ -268,16 +235,14 @@ public static class BlobEntry {
private final boolean m_isAsyncSpool;
private BlobEntry(byte[] bytes, File file, long size, Instant expiresAt,
- String credentials, CountDownLatch ready, CountDownLatch spoolStreamOpen,
- boolean isAsyncSpool) {
- this.m_bytes = bytes;
- this.m_file = file;
- this.size = size;
- this.expiresAt = expiresAt;
- this.credentials = credentials;
- this.m_ready = ready;
- this.m_spoolStreamOpen = spoolStreamOpen;
- this.m_isAsyncSpool = isAsyncSpool;
+ String credentials, CountDownLatch ready, boolean isAsyncSpool) {
+ this.m_bytes = bytes;
+ this.m_file = file;
+ this.size = size;
+ this.expiresAt = expiresAt;
+ this.credentials = credentials;
+ this.m_ready = ready;
+ this.m_isAsyncSpool = isAsyncSpool;
}
/**
@@ -288,15 +253,6 @@ public boolean isAsyncSpool() {
return m_isAsyncSpool;
}
- /**
- * Block until the spool thread has opened the JDBC stream (so the
- * ResultSet can be safely closed by the caller). Returns immediately
- * for non-async entries.
- */
- public void awaitSpoolStreamOpen() throws InterruptedException {
- m_spoolStreamOpen.await();
- }
-
/**
* Block until the spool thread has finished writing all bytes to disk.
* Returns immediately for in-memory / already-complete entries.
@@ -308,8 +264,8 @@ public void awaitReady() throws InterruptedException {
// -- factories --------------------------------------------------------
static BlobEntry ofBytes(byte[] bytes, Instant expiresAt, String credentials) {
- CountDownLatch zero = new CountDownLatch(0);
- return new BlobEntry(bytes, null, bytes.length, expiresAt, credentials, zero, zero, false);
+ return new BlobEntry(bytes, null, bytes.length, expiresAt, credentials,
+ new CountDownLatch(0), false);
}
static BlobEntry ofFile(byte[] bytes, Instant expiresAt, String credentials) throws IOException {
@@ -318,8 +274,8 @@ static BlobEntry ofFile(byte[] bytes, Instant expiresAt, String credentials) thr
try (FileOutputStream fos = new FileOutputStream(tmp)) {
fos.write(bytes);
}
- CountDownLatch zero = new CountDownLatch(0);
- return new BlobEntry(null, tmp, bytes.length, expiresAt, credentials, zero, zero, false);
+ return new BlobEntry(null, tmp, bytes.length, expiresAt, credentials,
+ new CountDownLatch(0), false);
}
/**
@@ -348,25 +304,19 @@ static BlobEntry ofBlobAsync(Blob blob, long declaredLength,
throw new IOException("Failed to open BLOB stream: " + e.getMessage(), e);
}
- // Both latches start at 1.
- // spoolStreamOpen: already counted down (stream is open) — kept at 0 so
- // isAsyncSpool() still works and awaitSpoolStreamOpen() returns immediately.
- // ready: counted down when all bytes are written to disk.
- CountDownLatch spoolStreamOpen = new CountDownLatch(0); // stream already open
- CountDownLatch ready = new CountDownLatch(1);
+ // ready: counted down to 0 when all bytes are written to disk.
+ CountDownLatch ready = new CountDownLatch(1);
BlobEntry entry = new BlobEntry(null, tmp, declaredLength,
Instant.MAX /* updated by spool thread on completion */,
- credentials, ready, spoolStreamOpen, true);
+ credentials, ready, true);
Thread spoolThread = new Thread(() -> {
try (FileOutputStream fos = new FileOutputStream(tmp);
InputStream in = blobStream) {
byte[] buf = new byte[65536];
int read;
- long total = 0;
while ((read = in.read(buf)) != -1) {
fos.write(buf, 0, read);
- total += read;
}
fos.flush();
entry.expiresAt = Instant.now().plusSeconds(s_ttlSeconds);
@@ -418,6 +368,19 @@ public InputStream openStream() throws IOException {
return new FileInputStream(m_file);
}
+ /**
+ * Returns the actual number of bytes available for streaming.
+ * For file-backed entries this is the real file size (which may differ
+ * from the JDBC-declared length for some IBM i LOB types). Falls back
+ * to the declared size for in-memory entries.
+ */
+ public long getActualSize() {
+ if (m_file != null && m_file.exists()) {
+ return m_file.length();
+ }
+ return size;
+ }
+
/** Delete the backing temp file if one exists. */
public void cleanup() {
if (m_file != null && m_file.exists()) {
diff --git a/src/main/java/com/github/ibm/mapepire/requests/BlockRetrievableRequest.java b/src/main/java/com/github/ibm/mapepire/requests/BlockRetrievableRequest.java
index 6096b6b..a846ab7 100644
--- a/src/main/java/com/github/ibm/mapepire/requests/BlockRetrievableRequest.java
+++ b/src/main/java/com/github/ibm/mapepire/requests/BlockRetrievableRequest.java
@@ -27,6 +27,12 @@ public abstract class BlockRetrievableRequest extends ClientRequest {
/** Holds a deferred ResultSet close (with pending spool entries) until after the WS reply is sent. */
private DataBlockFetchResult m_deferredFetchResult = null;
+ /**
+ * Tracks large output-param BLOBs that are being async-spooled.
+ * Drained in processAfterReplySent() alongside result-set spools.
+ */
+ private final List m_pendingOutputParamSpools = new LinkedList<>();
+
protected BlockRetrievableRequest(DataStreamProcessor _io, SystemConnection _conn, JsonObject _reqObj) {
super(_io, _conn, _reqObj);
m_isTerseData = getRequestFieldBoolean("terse", false);
@@ -59,7 +65,7 @@ List getOutputParms(PreparedStatement _stmt) throws SQLException {
parmInfo.put("index", i);
parmInfo.put("type", parmMeta.getParameterTypeName(i));
parmInfo.put("precision", parmMeta.getPrecision(i));
- parmInfo.put("scale", parmMeta.getScale(numParams));
+ parmInfo.put("scale", parmMeta.getScale(i));
if (parmMeta instanceof AS400JDBCParameterMetaData) {
AS400JDBCParameterMetaData db2ParmMeta = (AS400JDBCParameterMetaData) parmMeta;
parmInfo.put("name", db2ParmMeta.getDB2ParameterName(i));
@@ -87,9 +93,13 @@ List getOutputParms(PreparedStatement _stmt) throws SQLException {
jsonValue = null;
}
} else {
- DataBlockFetchResult dummy = new DataBlockFetchResult();
- BlobStore.BlobEntry bentry = serializeBlob(blob, blob.length(), conn, dummy);
- jsonValue = bentry != null ? blobEntryToRef(bentry, blob.length()) : null;
+ // Capture length before serializeBlob — for small blobs,
+ // storeAndReturnEntry calls blob.free(), so blob.length()
+ // after that throws HY010.
+ long blobLength = blob.length();
+ BlobStore.BlobEntry bentry = serializeBlob(blob, blobLength, conn,
+ m_pendingOutputParamSpools);
+ jsonValue = bentry != null ? blobEntryToRef(bentry, blobLength) : null;
}
} else if (value instanceof Clob) {
Clob clob = (Clob) value;
@@ -116,7 +126,7 @@ protected static class DataBlockFetchResult {
ResultSet m_deferredRs = null;
boolean m_deferredCloseStatement = false;
- /** BlobEntries from async spools — must call awaitSpoolStreamOpen() before closing RS. */
+ /** BlobEntries from async spools — awaited via awaitReady() before closing RS. */
final List m_pendingSpools = new LinkedList<>();
private DataBlockFetchResult setDone(final boolean _b) {
@@ -230,7 +240,7 @@ protected static DataBlockFetchResult getNextDataBlock(final ResultSet _rs, fina
// Capture length before serializeBlob, which calls blob.free()
// for small blobs — calling blob.length() after free() throws HY010.
long blobLength = blob.length();
- BlobStore.BlobEntry entry = serializeBlob(blob, blobLength, _conn, ret);
+ BlobStore.BlobEntry entry = serializeBlob(blob, blobLength, _conn, ret.m_pendingSpools);
cellDataForResponse = entry != null ? blobEntryToRef(entry, blobLength) : null;
}
} else if (cellData instanceof Clob) {
@@ -257,17 +267,23 @@ protected static DataBlockFetchResult getNextDataBlock(final ResultSet _rs, fina
// BLOB serialization helpers
// -------------------------------------------------------------------------
- /** Daemon-mode only: stores in {@link BlobStore}, tracks the entry for deferred RS close. */
+ /**
+ * Daemon-mode only: stores blob in {@link BlobStore} and tracks any async spool
+ * entry in the supplied list so the caller can defer closing the JDBC resource.
+ *
+ *
Accepts either a {@link DataBlockFetchResult#m_pendingSpools} list (result-set
+ * path) or {@link #m_pendingOutputParamSpools} (output-parameter path).
+ */
private static BlobStore.BlobEntry serializeBlob(Blob blob, long length,
SystemConnection conn,
- DataBlockFetchResult result) {
+ List pendingSpools) {
try {
// BlobStore.storeAndReturnEntry owns blob.free() from this point on
BlobStore.BlobEntry entry = BlobStore.getInstance().storeAndReturnEntry(blob, length, conn.getRawCredentials());
// Only track entries that have a live async spool thread — small blobs
// are stored synchronously and their latch is already at 0.
if (entry != null && entry.isAsyncSpool()) {
- result.m_pendingSpools.add(entry);
+ pendingSpools.add(entry);
}
return entry;
} catch (IOException e) {
@@ -279,7 +295,7 @@ private static BlobStore.BlobEntry serializeBlob(Blob blob, long length,
private static Map blobEntryToRef(BlobStore.BlobEntry entry, long length) {
Map ref = new LinkedHashMap<>();
- ref.put("blob_url", "/blob/" + entry.token);
+ ref.put("blob_url", "/blob/" + entry.getToken());
ref.put("size", length);
return ref;
}
@@ -317,7 +333,7 @@ public boolean isDone() {
/**
* Called by {@link com.github.ibm.mapepire.ClientRequest#run()} after the WebSocket
* reply has been sent. Closes any ResultSet that was deferred to allow async blob
- * spool threads to open their JDBC streams first.
+ * spool threads to finish writing, and waits for any output-parameter async spools.
*/
@Override
protected void processAfterReplySent() {
@@ -325,6 +341,13 @@ protected void processAfterReplySent() {
m_deferredFetchResult.closeDeferredResultSet();
m_deferredFetchResult = null;
}
+ // Drain any async output-parameter blob spools.
+ for (BlobStore.BlobEntry e : m_pendingOutputParamSpools) {
+ try { e.awaitReady(); } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ m_pendingOutputParamSpools.clear();
}
protected Map getResultMetaDataForResponse() throws SQLException {
@@ -350,6 +373,12 @@ public static Map getResultMetaDataForResponse(ResultSetMetaData
columnAttrs.put("readOnly", _md.isReadOnly(i));
columnAttrs.put("writeable", _md.isWritable(i));
columnAttrs.put("table", _md.getTableName(i));
+ // Signal to clients that this column's value will be a blob_url object
+ // in daemon mode rather than an inline string value.
+ int colType = _md.getColumnType(i);
+ boolean isBlobType = colType == Types.BLOB || colType == Types.BINARY
+ || colType == Types.VARBINARY || colType == Types.LONGVARBINARY;
+ columnAttrs.put("blob_as_url", isBlobType && !MapepireServer.isSingleMode());
columnMetaData.add(columnAttrs);
}
metaData.put("columns", columnMetaData);
diff --git a/src/main/java/com/github/ibm/mapepire/requests/PreparedExecute.java b/src/main/java/com/github/ibm/mapepire/requests/PreparedExecute.java
index 8501686..3b03ef3 100644
--- a/src/main/java/com/github/ibm/mapepire/requests/PreparedExecute.java
+++ b/src/main/java/com/github/ibm/mapepire/requests/PreparedExecute.java
@@ -104,6 +104,14 @@ private static void setParameterValue(PreparedStatement stmt, int i, String valu
}
break;
}
+ // Character LOBs — pass through as a plain string. Explicit cases here
+ // make intent clear and protect the default branch from future changes.
+ case Types.CLOB:
+ case Types.NCLOB:
+ case Types.LONGVARCHAR:
+ case Types.LONGNVARCHAR:
+ stmt.setString(i, value);
+ break;
default:
stmt.setString(i, value);
break;
diff --git a/src/main/java/com/github/ibm/mapepire/requests/RunSqlMore.java b/src/main/java/com/github/ibm/mapepire/requests/RunSqlMore.java
index 26eff77..2dc9ce6 100644
--- a/src/main/java/com/github/ibm/mapepire/requests/RunSqlMore.java
+++ b/src/main/java/com/github/ibm/mapepire/requests/RunSqlMore.java
@@ -20,4 +20,14 @@ protected void go() throws Exception {
addReplyData("is_done", m_prev.isDone());
}
+ /**
+ * Delegate to the previous request so that any deferred ResultSet close
+ * or pending output-param async spool is resolved after the WebSocket reply
+ * is sent — even though it is this RunSqlMore instance whose run() executes. (Fix 4)
+ */
+ @Override
+ protected void processAfterReplySent() {
+ m_prev.processAfterReplySent();
+ }
+
}