diff --git a/src/main/java/com/github/ibm/mapepire/MapepireServer.java b/src/main/java/com/github/ibm/mapepire/MapepireServer.java
index 82750ec..0d30f2b 100644
--- a/src/main/java/com/github/ibm/mapepire/MapepireServer.java
+++ b/src/main/java/com/github/ibm/mapepire/MapepireServer.java
@@ -26,6 +26,7 @@
import com.github.ibm.mapepire.authfile.AuthFile;
import com.github.ibm.mapepire.certstuff.ServerCertGetter;
import com.github.ibm.mapepire.certstuff.ServerCertInfo;
+import com.github.ibm.mapepire.http.BlobServlet;
import com.github.ibm.mapepire.http.InstallLocationServlet;
import com.github.ibm.mapepire.http.Routes;
import com.github.ibm.mapepire.http.VersionServlet;
@@ -122,6 +123,7 @@ public static void main(final String[] _args) {
context.setContextPath("/");
context.addServlet(new ServletHolder(VersionServlet.class), Routes.VERSION);
context.addServlet(new ServletHolder(InstallLocationServlet.class), Routes.SOURCE);
+ context.addServlet(new ServletHolder(BlobServlet.class), Routes.BLOB);
Constraint constraint = new Constraint();
constraint.setName("Disable TRACE");
@@ -175,8 +177,9 @@ public static void main(final String[] _args) {
NativeWebSocketServletContainerInitializer.configure(context,
(servletContext, nativeWebSocketConfiguration) -> {
nativeWebSocketConfiguration.getPolicy().setMaxTextMessageBufferSize(65535);
- // Configure max message size
- int maxWsMessageSize = 50 * 1024 * 1024; // 50MB
+ // Max WS message size — default Integer.MAX_VALUE (unlimited).
+ // Can be capped via MAX_WS_MESSAGE_SIZE env var if desired.
+ int maxWsMessageSize = Integer.MAX_VALUE;
String maxWsMessageSizeStr = System.getenv("MAX_WS_MESSAGE_SIZE");
if (StringUtils.isNonEmpty(maxWsMessageSizeStr)) {
maxWsMessageSize = Integer.parseInt(maxWsMessageSizeStr);
diff --git a/src/main/java/com/github/ibm/mapepire/SystemConnection.java b/src/main/java/com/github/ibm/mapepire/SystemConnection.java
index e798478..fd61168 100644
--- a/src/main/java/com/github/ibm/mapepire/SystemConnection.java
+++ b/src/main/java/com/github/ibm/mapepire/SystemConnection.java
@@ -25,6 +25,9 @@ public enum ConnectionMethod {
private final ClientSpecialRegisters m_clientRegs;
private String m_applicationName;
private final String clientAddress;
+ // Raw Base64 "user:pass" from the WebSocket Authorization header.
+ // Stored so BlobStore can validate HTTP /blob/{token} requests.
+ private String m_rawCredentials = null;
public SystemConnection() throws IOException {
if (!MapepireServer.isSingleMode()) {
@@ -57,6 +60,16 @@ public SystemConnection(String clientHost, String clientAddress, String host, St
this.m_clientRegs = new ClientSpecialRegistersRemote(clientHost, clientAddress, user);
}
+ /** Returns the raw Base64 Authorization credentials, or {@code null} in single mode. */
+ public String getRawCredentials() {
+ return m_rawCredentials;
+ }
+
+ /** Called by {@link com.github.ibm.mapepire.ws.DbWebsocketClient} at connection time. */
+ public void setRawCredentials(String rawCredentials) {
+ this.m_rawCredentials = rawCredentials;
+ }
+
public static boolean isRunningOnIBMi() {
return System.getProperty("os.name", "").contains("400");
}
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
new file mode 100644
index 0000000..261fc5f
--- /dev/null
+++ b/src/main/java/com/github/ibm/mapepire/http/BlobServlet.java
@@ -0,0 +1,106 @@
+package com.github.ibm.mapepire.http;
+
+import com.github.ibm.mapepire.Tracer;
+
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Streams a previously-stored BLOB to the caller.
+ *
+ *
URL pattern: {@code GET /blob/{token}}
+ *
+ *
The caller must supply the same Basic-Auth credentials that were used
+ * when the originating WebSocket connection ran the query. The token itself
+ * is single-use and expires after the configured TTL.
+ *
+ *
For large BLOBs the backing data may still be spooling to disk when this
+ * request arrives. {@link BlobStore.BlobEntry#openStream()} will block until
+ * the spool is complete before any response bytes are written, ensuring the
+ * HTTP status code is always set correctly before the body begins.
+ */
+public class BlobServlet extends HttpServlet {
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
+
+ // ---- Extract token from path /blob/{token} -------------------------
+ String pathInfo = req.getPathInfo();
+ if (pathInfo == null || pathInfo.length() <= 1) {
+ resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing blob token");
+ return;
+ }
+ String token = pathInfo.substring(1); // strip leading '/'
+
+ // ---- Validate Basic Auth --------------------------------------------
+ String authHeader = req.getHeader("Authorization");
+ if (authHeader == null || !authHeader.startsWith("Basic ")) {
+ resp.setHeader("WWW-Authenticate", "Basic realm=\"mapepire\"");
+ resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization required");
+ return;
+ }
+ String suppliedCredentials = authHeader.substring(6).trim(); // raw Base64 "user:pass"
+
+ // ---- Consume token --------------------------------------------------
+ BlobStore.BlobEntry entry = BlobStore.getInstance().consume(token);
+ if (entry == null) {
+ resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Blob token not found or expired");
+ return;
+ }
+
+ // ---- Verify credentials match ---------------------------------------
+ if (!suppliedCredentials.equals(entry.credentials)) {
+ // Put the entry back? No — single-use, deny and discard to prevent brute-force
+ entry.cleanup();
+ resp.setHeader("WWW-Authenticate", "Basic realm=\"mapepire\"");
+ resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid credentials");
+ return;
+ }
+
+ // ---- Wait for spool (if still in progress) and open stream ----------
+ // openStream() blocks until the background spool thread finishes or the
+ // TTL elapses. We must open the stream BEFORE committing any response
+ // headers so that a spool failure can still be reported as a 500.
+ InputStream in = null;
+ try {
+ in = entry.openStream();
+ } catch (IOException e) {
+ entry.cleanup();
+ Tracer.err(e);
+ resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ "Blob data unavailable");
+ return;
+ }
+
+ // ---- 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-Disposition", "attachment; filename=\"blob\"");
+ resp.setHeader("Content-Length", String.valueOf(actualSize));
+ resp.setHeader("Cache-Control", "no-store");
+
+ try {
+ OutputStream out = resp.getOutputStream();
+ byte[] buf = new byte[65536];
+ int read;
+ while ((read = in.read(buf)) != -1) {
+ out.write(buf, 0, read);
+ }
+ out.flush();
+ // 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
new file mode 100644
index 0000000..8cf96f6
--- /dev/null
+++ b/src/main/java/com/github/ibm/mapepire/http/BlobStore.java
@@ -0,0 +1,391 @@
+package com.github.ibm.mapepire.http;
+
+import com.github.ibm.mapepire.Tracer;
+
+import java.io.*;
+import java.nio.file.Files;
+import java.sql.Blob;
+import java.sql.SQLException;
+import java.time.Instant;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Singleton store for BLOB tokens.
+ *
+ *
BLOBs <= {@link #MEMORY_THRESHOLD_BYTES} are held in a {@code byte[]}.
+ * BLOBs above that threshold are spooled to a JVM temp file in a background
+ * thread so that the WebSocket response (carrying the {@code blob_url}) is
+ * returned to the client immediately, without waiting for the full spool to
+ * complete. The HTTP GET on {@code /blob/{token}} will wait (up to the TTL)
+ * for the spool to finish before streaming bytes.
+ *
+ *
Each entry carries the Basic-Auth credentials of the connection that
+ * produced it so that {@link BlobServlet} can re-validate the caller.
+ *
+ *
A background thread sweeps expired entries every 30 seconds.
+ */
+public class BlobStore {
+
+ // BLOBs larger than this are spooled to disk instead of held in memory
+ public static final int MEMORY_THRESHOLD_BYTES = 1024 * 1024; // 1 MB
+
+ // Default TTL in seconds — overridable via BLOB_TOKEN_TTL env var
+ private static volatile long s_ttlSeconds = 60;
+
+ private static final BlobStore s_instance = new BlobStore();
+
+ private final Map m_entries = new ConcurrentHashMap<>();
+ private final ScheduledExecutorService m_sweeper =
+ Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "BlobStore-sweeper");
+ t.setDaemon(true);
+ return t;
+ });
+
+ private BlobStore() {
+ // Read TTL from environment on startup; override at runtime via setTtlSeconds().
+ String envTtl = System.getenv("BLOB_TOKEN_TTL");
+ if (envTtl != null && !envTtl.isEmpty()) {
+ try {
+ s_ttlSeconds = Long.parseLong(envTtl.trim());
+ } catch (NumberFormatException e) {
+ Tracer.warn("Invalid BLOB_TOKEN_TTL value '" + envTtl + "', using default " + s_ttlSeconds + "s");
+ }
+ }
+ m_sweeper.scheduleAtFixedRate(this::sweepExpired, 30, 30, TimeUnit.SECONDS);
+ }
+
+ public static BlobStore getInstance() {
+ return s_instance;
+ }
+
+ // -------------------------------------------------------------------------
+ // 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
+ // -------------------------------------------------------------------------
+
+ /**
+ * Store raw bytes and return a single-use token.
+ *
+ * @param data the BLOB bytes
+ * @param credentials Base64-encoded "user:pass" copied from the WebSocket
+ * Authorization header — used to re-validate on retrieval
+ * @return opaque token (UUID string) to embed in the response as a URL path segment
+ */
+ public String store(byte[] data, String credentials) throws IOException {
+ String token = UUID.randomUUID().toString();
+ Instant expiresAt = Instant.now().plusSeconds(s_ttlSeconds);
+
+ BlobEntry entry;
+ if (data.length <= MEMORY_THRESHOLD_BYTES) {
+ entry = BlobEntry.ofBytes(data, expiresAt, credentials);
+ } else {
+ entry = BlobEntry.ofFile(data, expiresAt, credentials);
+ }
+ m_entries.put(token, entry);
+ Tracer.info("BlobStore: stored token " + token + " size=" + data.length + " expires=" + expiresAt);
+ return token;
+ }
+
+ /**
+ * 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.
+ *
+ *
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.
+ *
+ *
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();
+ 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.m_token = tok;
+ m_entries.put(tok, entry);
+ return entry;
+ }
+
+ // -------------------------------------------------------------------------
+ // Retrieving blobs
+ // -------------------------------------------------------------------------
+
+ /**
+ * Retrieve and consume a token. Returns {@code null} if the token
+ * is unknown or has expired. The entry is removed immediately on retrieval
+ * (single-use) and any temp file is deleted after streaming.
+ *
+ *
If the backing spool is still in progress, this method returns the
+ * entry anyway — {@link BlobEntry#openStream()} will block until the spool
+ * completes or the TTL elapses.
+ */
+ public BlobEntry consume(String token) {
+ BlobEntry entry = m_entries.remove(token);
+ if (entry == null) {
+ return null;
+ }
+ if (Instant.now().isAfter(entry.expiresAt)) {
+ entry.cleanup();
+ return null;
+ }
+ return entry;
+ }
+
+ // -------------------------------------------------------------------------
+ // Java 8 compatible helpers
+ // -------------------------------------------------------------------------
+
+ private static byte[] readAllBytes(InputStream in) throws IOException {
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ byte[] chunk = new byte[65536];
+ int read;
+ while ((read = in.read(chunk)) != -1) {
+ buf.write(chunk, 0, read);
+ }
+ return buf.toByteArray();
+ }
+
+ // -------------------------------------------------------------------------
+ // Expiry sweep
+ // -------------------------------------------------------------------------
+
+ private void sweepExpired() {
+ Instant now = Instant.now();
+ Iterator> it = m_entries.entrySet().iterator();
+ while (it.hasNext()) {
+ Map.Entry e = it.next();
+ if (now.isAfter(e.getValue().expiresAt)) {
+ Tracer.info("BlobStore: expiring token " + e.getKey());
+ e.getValue().cleanup();
+ it.remove();
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // BlobEntry — internal value type
+ // -------------------------------------------------------------------------
+
+ public static class BlobEntry {
+ // Exactly one of these is set once the entry is ready
+ private volatile byte[] m_bytes;
+ private volatile File m_file;
+
+ public final long size;
+ // 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 {@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
+ * (immediately for in-memory/already-spooled entries; when the background
+ * spool thread finishes writing for large blobs).
+ */
+ private final CountDownLatch m_ready;
+
+ /**
+ * 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, 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;
+ }
+
+ /**
+ * 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 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) {
+ return new BlobEntry(bytes, null, bytes.length, expiresAt, credentials,
+ new CountDownLatch(0), false);
+ }
+
+ static BlobEntry ofFile(byte[] bytes, Instant expiresAt, String credentials) throws IOException {
+ File tmp = Files.createTempFile("mapepire-blob-", ".tmp").toFile();
+ tmp.deleteOnExit();
+ try (FileOutputStream fos = new FileOutputStream(tmp)) {
+ fos.write(bytes);
+ }
+ return new BlobEntry(null, tmp, bytes.length, expiresAt, credentials,
+ new CountDownLatch(0), false);
+ }
+
+ /**
+ * Opens {@link Blob#getBinaryStream()} on the calling thread before
+ * returning, then spools the bytes to a temp file in a background thread.
+ *
+ *
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,
+ String tokenForLogging) throws IOException {
+ File tmp = Files.createTempFile("mapepire-blob-", ".tmp").toFile();
+ tmp.deleteOnExit();
+
+ // 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);
+ }
+
+ // 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, true);
+
+ Thread spoolThread = new Thread(() -> {
+ try (FileOutputStream fos = new FileOutputStream(tmp);
+ InputStream in = blobStream) {
+ byte[] buf = new byte[65536];
+ int read;
+ while ((read = in.read(buf)) != -1) {
+ fos.write(buf, 0, read);
+ }
+ fos.flush();
+ entry.expiresAt = Instant.now().plusSeconds(s_ttlSeconds);
+ } catch (IOException e) {
+ entry.m_spoolError = e;
+ entry.expiresAt = Instant.now();
+ } finally {
+ try { blob.free(); } catch (SQLException ignored) {}
+ ready.countDown();
+ }
+ }, "BlobStore-spool-" + tokenForLogging);
+ spoolThread.setDaemon(true);
+ spoolThread.start();
+
+ return entry;
+ }
+
+ // -- accessors --------------------------------------------------------
+
+ /**
+ * Open an InputStream over this entry's data.
+ *
+ *
If the blob is still being spooled to disk, this method blocks
+ * until the spool finishes or the TTL elapses (whichever comes first).
+ * If the spool fails or the wait times out, an {@link IOException} is
+ * thrown and the caller should invoke {@link #cleanup()} in a
+ * {@code finally} block.
+ *
+ *
Caller is responsible for closing the returned stream.
+ */
+ public InputStream openStream() throws IOException {
+ if (m_ready.getCount() > 0) {
+ // 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 {
+ m_ready.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted while waiting for blob spool", e);
+ }
+ }
+ if (m_spoolError != null) {
+ throw new IOException("Blob spool failed: " + m_spoolError.getMessage(), m_spoolError);
+ }
+ if (m_bytes != null) {
+ return new ByteArrayInputStream(m_bytes);
+ }
+ 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()) {
+ m_file.delete();
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/github/ibm/mapepire/http/Routes.java b/src/main/java/com/github/ibm/mapepire/http/Routes.java
index 6420ef0..e11548d 100644
--- a/src/main/java/com/github/ibm/mapepire/http/Routes.java
+++ b/src/main/java/com/github/ibm/mapepire/http/Routes.java
@@ -3,4 +3,5 @@
public class Routes {
public static final String VERSION = "/version";
public static final String SOURCE = "/source";
+ public static final String BLOB = "/blob/*";
}
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..a846ab7 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,9 @@
package com.github.ibm.mapepire.requests;
+import java.io.IOException;
+import java.io.InputStream;
import java.sql.*;
+import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
@@ -8,7 +11,10 @@
import com.github.ibm.mapepire.ClientRequest;
import com.github.ibm.mapepire.DataStreamProcessor;
+import com.github.ibm.mapepire.MapepireServer;
import com.github.ibm.mapepire.SystemConnection;
+import com.github.ibm.mapepire.Tracer;
+import com.github.ibm.mapepire.http.BlobStore;
import com.google.gson.JsonObject;
import com.ibm.as400.access.AS400JDBCParameterMetaData;
@@ -18,6 +24,15 @@ 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;
+
+ /**
+ * 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);
@@ -27,8 +42,12 @@ List