From a2c9586752e9ea731424f8636762a1833365c7ee Mon Sep 17 00:00:00 2001 From: Wild Me Date: Fri, 27 Mar 2026 15:01:42 -0700 Subject: [PATCH 1/9] Switch to temp file model to avoid timeouts --- .../export/EncounterCOCOExportFile.java | 121 ++++++++++-------- .../export/EncounterSearchExportCOCO.java | 52 +++++--- .../export/EncounterCOCOExportFileTest.java | 45 ++----- 3 files changed, 112 insertions(+), 106 deletions(-) diff --git a/src/main/java/org/ecocean/export/EncounterCOCOExportFile.java b/src/main/java/org/ecocean/export/EncounterCOCOExportFile.java index d931e079ac..c83f81714e 100644 --- a/src/main/java/org/ecocean/export/EncounterCOCOExportFile.java +++ b/src/main/java/org/ecocean/export/EncounterCOCOExportFile.java @@ -52,50 +52,13 @@ public void writeTo(OutputStream outputStream) throws IOException { mediaAssetToImageId.put(uuid, imageId++); } - // Build JSON arrays - JSONArray imagesArray = new JSONArray(); - for (Map.Entry entry : mediaAssetMap.entrySet()) { - MediaAsset ma = entry.getValue(); - int imgId = mediaAssetToImageId.get(entry.getKey()); - imagesArray.put(buildImageObject(ma, imgId)); - } - - JSONArray annotationsArray = new JSONArray(); - int annotationId = 1; - for (Encounter enc : encounters) { - if (enc.getAnnotations() == null) continue; - for (Annotation ann : enc.getAnnotations()) { - if (!isValidAnnotation(ann)) continue; - MediaAsset ma = ann.getMediaAsset(); - int imgId = mediaAssetToImageId.get(ma.getUUID()); - annotationsArray.put(buildAnnotationObject(ann, annotationId++, imgId, - categoryMap, individualIdMap, enc)); - } - } - - // Build complete COCO JSON - JSONObject coco = new JSONObject(); - coco.put("info", buildInfo(individualIdMap)); - coco.put("licenses", buildLicenses()); - coco.put("categories", buildCategories(categoryMap)); - coco.put("images", imagesArray); - coco.put("annotations", annotationsArray); - - // Write ZIP + // Write ZIP: images first, then JSON manifest so it only references + // images that were actually written successfully. try (ZipOutputStream zipOut = new ZipOutputStream(outputStream)) { - // Write annotations.json - log.info("COCO Export: Writing annotations JSON..."); - byte[] jsonBytes = coco.toString(2).getBytes(StandardCharsets.UTF_8); - ZipEntry jsonEntry = new ZipEntry("coco/annotations/instances.json"); - zipOut.putNextEntry(jsonEntry); - zipOut.write(jsonBytes); - zipOut.closeEntry(); - zipOut.flush(); - - // Write images with streaming to minimize memory usage int totalImages = mediaAssetMap.size(); int processedImages = 0; int failedImages = 0; + Set exportedUuids = new LinkedHashSet<>(); log.info("COCO Export: Starting export of " + totalImages + " images..."); for (Map.Entry entry : mediaAssetMap.entrySet()) { @@ -103,22 +66,61 @@ public void writeTo(OutputStream outputStream) throws IOException { processedImages++; try { boolean success = writeImageToZip(ma, zipOut); - if (!success) { + if (success) { + exportedUuids.add(entry.getKey()); + } else { failedImages++; } - // Progress logging every 100 images if (processedImages % 100 == 0) { log.info("COCO Export: Processed " + processedImages + "/" + totalImages + " images (" + failedImages + " failed)"); } } catch (Exception e) { failedImages++; - log.warning("COCO Export: Failed to export image " + ma.getUUID() + ": " + e.getMessage()); + log.warning("COCO Export: Failed to export image " + ma.getUUID() + + ": " + e.getMessage()); + } + } + + // Build JSON arrays using only successfully exported images + JSONArray imagesArray = new JSONArray(); + for (String uuid : exportedUuids) { + MediaAsset ma = mediaAssetMap.get(uuid); + int imgId = mediaAssetToImageId.get(uuid); + imagesArray.put(buildImageObject(ma, imgId)); + } + + JSONArray annotationsArray = new JSONArray(); + int annotationId = 1; + for (Encounter enc : encounters) { + if (enc.getAnnotations() == null) continue; + for (Annotation ann : enc.getAnnotations()) { + if (!isValidAnnotation(ann)) continue; + MediaAsset ma = ann.getMediaAsset(); + if (!exportedUuids.contains(ma.getUUID())) continue; + int imgId = mediaAssetToImageId.get(ma.getUUID()); + annotationsArray.put(buildAnnotationObject(ann, annotationId++, imgId, + categoryMap, individualIdMap, enc)); } } + JSONObject coco = new JSONObject(); + coco.put("info", buildInfo(individualIdMap)); + coco.put("licenses", buildLicenses()); + coco.put("categories", buildCategories(categoryMap)); + coco.put("images", imagesArray); + coco.put("annotations", annotationsArray); + + // Write annotations.json as the last entry + log.info("COCO Export: Writing annotations JSON..."); + byte[] jsonBytes = coco.toString(2).getBytes(StandardCharsets.UTF_8); + ZipEntry jsonEntry = new ZipEntry("coco/annotations/instances.json"); + zipOut.putNextEntry(jsonEntry); + zipOut.write(jsonBytes); + zipOut.closeEntry(); + zipOut.finish(); - log.info("COCO Export: Completed. " + (processedImages - failedImages) + "/" + totalImages + + log.info("COCO Export: Completed. " + exportedUuids.size() + "/" + totalImages + " images exported successfully, " + failedImages + " failed."); } } @@ -162,23 +164,19 @@ private boolean writeImageToZip(MediaAsset ma, ZipOutputStream zipOut) throws IO * Detects the content type of a URL, handling redirects. */ private String detectContentType(URL url) { + HttpURLConnection httpConn = null; try { URLConnection conn = url.openConnection(); conn.setConnectTimeout(CONNECTION_TIMEOUT_MS); conn.setReadTimeout(READ_TIMEOUT_MS); if (conn instanceof HttpURLConnection) { - ((HttpURLConnection) conn).setRequestMethod("HEAD"); - ((HttpURLConnection) conn).setInstanceFollowRedirects(true); - } - - String contentType = conn.getContentType(); - - if (conn instanceof HttpURLConnection) { - ((HttpURLConnection) conn).disconnect(); + httpConn = (HttpURLConnection) conn; + httpConn.setRequestMethod("HEAD"); + httpConn.setInstanceFollowRedirects(true); } - return contentType; + return conn.getContentType(); } catch (Exception e) { // Fall back to checking file extension String path = url.getPath().toLowerCase(); @@ -186,6 +184,10 @@ private String detectContentType(URL url) { return "image/jpeg"; } return null; + } finally { + if (httpConn != null) { + httpConn.disconnect(); + } } } @@ -247,12 +249,19 @@ private void convertAndWriteImage(URL url, ZipOutputStream zipOut) throws IOExce if (image.getColorModel().hasAlpha()) { BufferedImage rgbImage = new BufferedImage( image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB); - rgbImage.createGraphics().drawImage(image, 0, 0, java.awt.Color.WHITE, null); + java.awt.Graphics2D g2d = rgbImage.createGraphics(); + try { + g2d.drawImage(image, 0, 0, java.awt.Color.WHITE, null); + } finally { + g2d.dispose(); + } image = rgbImage; } - // Write directly to zip stream - ImageIO.write(image, "jpg", zipOut); + // Buffer the JPEG output to avoid ImageIO closing the ZipOutputStream + java.io.ByteArrayOutputStream imgBuf = new java.io.ByteArrayOutputStream(); + ImageIO.write(image, "jpg", imgBuf); + zipOut.write(imgBuf.toByteArray()); } finally { if (conn instanceof HttpURLConnection) { ((HttpURLConnection) conn).disconnect(); diff --git a/src/main/java/org/ecocean/servlet/export/EncounterSearchExportCOCO.java b/src/main/java/org/ecocean/servlet/export/EncounterSearchExportCOCO.java index 913a3055c5..ca3d09f19e 100644 --- a/src/main/java/org/ecocean/servlet/export/EncounterSearchExportCOCO.java +++ b/src/main/java/org/ecocean/servlet/export/EncounterSearchExportCOCO.java @@ -14,9 +14,12 @@ import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.PrintWriter; +import java.nio.file.Files; import java.util.ArrayList; import java.util.List; import java.util.Vector; @@ -41,16 +44,14 @@ public void doPost(HttpServletRequest request, HttpServletResponse response) myShepherd.beginDBTransaction(); + File tempFile = null; try { - // Process query + // Query and filter encounters (requires open DB transaction) EncounterQueryResult queryResult = EncounterQueryProcessor.processQuery( myShepherd, request, "year descending, month descending, day descending"); Vector rEncounters = queryResult.getResult(); - // Filter hidden encounters HiddenEncReporter hiddenData = new HiddenEncReporter(rEncounters, request, myShepherd); - - // Convert to list, excluding hidden List encounters = new ArrayList<>(); for (Object obj : rEncounters) { Encounter enc = (Encounter) obj; @@ -59,29 +60,44 @@ public void doPost(HttpServletRequest request, HttpServletResponse response) } } - // Set response headers - response.setContentType("application/zip"); - response.setHeader("Content-Disposition", "attachment; filename=\"wildbook-coco-export.zip\""); + // Build ZIP to temp file so we can detect errors before committing + // the HTTP response and set an accurate Content-Length. + File tmpDir = new File(CommonConfiguration.getUploadTmpDir(context)); + if (!tmpDir.exists()) tmpDir.mkdirs(); + tempFile = File.createTempFile("wildbook-coco-export-", ".zip", tmpDir); + tempFile.deleteOnExit(); + EncounterCOCOExportFile exportFile = new EncounterCOCOExportFile(encounters, myShepherd); + try (FileOutputStream fos = new FileOutputStream(tempFile)) { + exportFile.writeTo(fos); + } - // Write export + // Stream complete file to client + response.setContentType("application/zip"); + response.setHeader("Content-Disposition", + "attachment; filename=\"wildbook-coco-export.zip\""); + response.setContentLengthLong(tempFile.length()); OutputStream out = response.getOutputStream(); - EncounterCOCOExportFile exportFile = new EncounterCOCOExportFile(encounters, myShepherd); - exportFile.writeTo(out); + Files.copy(tempFile.toPath(), out); out.flush(); } catch (Exception e) { e.printStackTrace(); - response.setContentType("text/html"); - PrintWriter out = response.getWriter(); - out.println(ServletUtilities.getHeader(request)); - out.println("

Error encountered

"); - out.println("

Error: " + e.getMessage() + "

"); - out.println("

Please let the webmaster know you encountered an error at: EncounterSearchExportCOCO servlet

"); - out.println(ServletUtilities.getFooter(context)); - out.close(); + if (!response.isCommitted()) { + response.setContentType("text/html"); + PrintWriter out = response.getWriter(); + out.println(ServletUtilities.getHeader(request)); + out.println("

Error encountered

"); + out.println("

Error: " + e.getMessage() + "

"); + out.println("

Please let the webmaster know you encountered an error at: EncounterSearchExportCOCO servlet

"); + out.println(ServletUtilities.getFooter(context)); + out.close(); + } } finally { myShepherd.rollbackDBTransaction(); myShepherd.closeDBTransaction(); + if (tempFile != null) { + tempFile.delete(); + } } } } diff --git a/src/test/java/org/ecocean/export/EncounterCOCOExportFileTest.java b/src/test/java/org/ecocean/export/EncounterCOCOExportFileTest.java index 97ec570347..b8b119b7a2 100644 --- a/src/test/java/org/ecocean/export/EncounterCOCOExportFileTest.java +++ b/src/test/java/org/ecocean/export/EncounterCOCOExportFileTest.java @@ -24,28 +24,17 @@ class EncounterCOCOExportFileTest { @Test - void testBuildsCOCOStructure() throws Exception { - // Create mock objects + void testManifestExcludesFailedImages() throws Exception { + // When an image URL is null (download fails), the manifest should NOT + // reference that image or its annotations — ensuring consistency. Shepherd shepherd = mock(Shepherd.class); - // Create a mock MediaAsset MediaAsset ma = mock(MediaAsset.class); when(ma.getUUID()).thenReturn("test-media-uuid"); when(ma.getWidth()).thenReturn(800.0); when(ma.getHeight()).thenReturn(600.0); - when(ma.webURL()).thenReturn(null); // Skip actual image download in test - - // Create a mock Feature with bbox - Feature feature = mock(Feature.class); - JSONObject params = new JSONObject(); - params.put("x", 100); - params.put("y", 200); - params.put("width", 300); - params.put("height", 400); - params.put("theta", 0.5); - when(feature.getParameters()).thenReturn(params); - - // Create a mock Annotation + when(ma.webURL()).thenReturn(null); // image cannot be fetched + Annotation ann = mock(Annotation.class); when(ann.getId()).thenReturn("test-ann-uuid"); when(ann.getIAClass()).thenReturn("whale_shark"); @@ -54,12 +43,10 @@ void testBuildsCOCOStructure() throws Exception { when(ann.getViewpoint()).thenReturn("left"); when(ann.getTheta()).thenReturn(0.5); - // Create a mock MarkedIndividual MarkedIndividual ind = mock(MarkedIndividual.class); when(ind.getId()).thenReturn("test-individual-uuid"); when(ind.getDisplayName()).thenReturn("Stumpy"); - // Create a mock Encounter Encounter enc = mock(Encounter.class); ArrayList annotations = new ArrayList<>(); annotations.add(ann); @@ -69,19 +56,16 @@ void testBuildsCOCOStructure() throws Exception { List encounters = new ArrayList<>(); encounters.add(enc); - // Run export ByteArrayOutputStream baos = new ByteArrayOutputStream(); EncounterCOCOExportFile exportFile = new EncounterCOCOExportFile(encounters, shepherd); exportFile.writeTo(baos); - // Parse ZIP and extract annotations.json byte[] zipBytes = baos.toByteArray(); assertTrue(zipBytes.length > 0, "Export should produce output"); String jsonContent = extractJsonFromZip(zipBytes); assertNotNull(jsonContent, "Should contain annotations.json"); - // Verify JSON structure JSONObject coco = new JSONObject(jsonContent); assertTrue(coco.has("info")); assertTrue(coco.has("licenses")); @@ -89,21 +73,18 @@ void testBuildsCOCOStructure() throws Exception { assertTrue(coco.has("images")); assertTrue(coco.has("annotations")); - // Verify categories + // Image failed to export, so both images and annotations arrays should be empty + assertEquals(0, coco.getJSONArray("images").length(), + "Failed images should be excluded from manifest"); + assertEquals(0, coco.getJSONArray("annotations").length(), + "Annotations for failed images should be excluded from manifest"); + + // Categories are built from encounter data, independent of image success JSONArray categories = coco.getJSONArray("categories"); assertEquals(1, categories.length()); assertEquals("whale_shark", categories.getJSONObject(0).getString("name")); - // Verify annotations - JSONArray anns = coco.getJSONArray("annotations"); - assertEquals(1, anns.length()); - JSONObject annJson = anns.getJSONObject(0); - assertEquals("left", annJson.getString("viewpoint")); - assertEquals(0.5, annJson.getDouble("theta"), 0.001); - assertEquals("test-individual-uuid", annJson.getString("individual_uuid")); - assertEquals("Stumpy", annJson.getString("name")); - - // Verify individual_id_mapping in info + // Individual mapping is also independent of image success JSONObject info = coco.getJSONObject("info"); assertTrue(info.has("individual_id_mapping")); JSONObject mapping = info.getJSONObject("individual_id_mapping"); From dd644c80e2b3af81cb501da24b1faf8f2aa030f1 Mon Sep 17 00:00:00 2001 From: Wild Me Date: Mon, 6 Apr 2026 12:03:29 -0700 Subject: [PATCH 2/9] Update with Codex code review and changed architecture --- .claude/settings.local.json | 10 + .../SearchPages/components/ExportModal.jsx | 100 +++++++- .../export/EncounterCOCOExportFile.java | 25 +- .../export/EncounterSearchExportCOCO.java | 213 ++++++++++++++++-- 4 files changed, 316 insertions(+), 32 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..d55bcc6706 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(git log:*)", + "Bash(gh pr:*)", + "Bash(mvn compile:*)", + "Bash(mvn test:*)" + ] + } +} diff --git a/frontend/src/pages/SearchPages/components/ExportModal.jsx b/frontend/src/pages/SearchPages/components/ExportModal.jsx index 6acc928e49..edfb5d0501 100644 --- a/frontend/src/pages/SearchPages/components/ExportModal.jsx +++ b/frontend/src/pages/SearchPages/components/ExportModal.jsx @@ -11,7 +11,7 @@ import { Spinner, Alert, } from "react-bootstrap"; -import { useState } from "react"; +import { useState, useRef, useCallback, useEffect } from "react"; import { FormattedMessage } from "react-intl"; const downloadFunction = async (url, setLoading) => { @@ -75,6 +75,18 @@ export default function ExportDialog({ open, setOpen, searchQueryId }) { }); const [error, setError] = useState(null); + const [cocoProgress, setCocoProgress] = useState(null); + const cocoPollingRef = useRef(null); + + // Clean up polling interval on unmount (e.g., modal close) + useEffect(() => { + return () => { + if (cocoPollingRef.current) { + clearInterval(cocoPollingRef.current); + cocoPollingRef.current = null; + } + }; + }, []); const setLoading = (key, value) => { setLoadingStates((prev) => ({ ...prev, [key]: value })); @@ -90,6 +102,78 @@ export default function ExportDialog({ open, setOpen, searchQueryId }) { } }; + const handleCocoExport = useCallback(async () => { + setError(null); + setLoading("cocoFormat", true); + setCocoProgress(null); + + try { + // Start the async export job + const startUrl = `/EncounterSearchExportCOCO?action=start&searchQueryId=${searchQueryId}®ularQuery=true`; + const startResp = await fetch(startUrl); + const startData = await startResp.json(); + if (!startResp.ok || !startData.jobId) { + throw new Error(startData.error || "Failed to start export"); + } + const { jobId } = startData; + + // Poll for progress + const result = await new Promise((resolve, reject) => { + cocoPollingRef.current = setInterval(async () => { + try { + const statusResp = await fetch( + `/EncounterSearchExportCOCO?action=status&jobId=${jobId}`, + ); + const status = await statusResp.json(); + + if (status.totalImages > 0) { + setCocoProgress(status); + } + + if (status.status === "complete") { + clearInterval(cocoPollingRef.current); + cocoPollingRef.current = null; + resolve(jobId); + } else if (status.status === "error") { + clearInterval(cocoPollingRef.current); + cocoPollingRef.current = null; + reject(new Error(status.error || "Export failed")); + } + } catch (e) { + clearInterval(cocoPollingRef.current); + cocoPollingRef.current = null; + reject(e); + } + }, 3000); + }); + + // Download the completed file + const downloadResp = await fetch( + `/EncounterSearchExportCOCO?action=download&jobId=${result}`, + ); + if (!downloadResp.ok) throw new Error("Download failed"); + const blob = await downloadResp.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = downloadUrl; + a.download = "wildbook-coco-export.zip"; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(downloadUrl); + } catch (err) { + console.error("COCO export error:", err); + setError(`Failed to export: ${err.message}`); + } finally { + if (cocoPollingRef.current) { + clearInterval(cocoPollingRef.current); + cocoPollingRef.current = null; + } + setLoading("cocoFormat", false); + setCocoProgress(null); + } + }, [searchQueryId]); + const scrollToSection = (sectionId) => { setActiveSection(sectionId); const element = document.getElementById(sectionId); @@ -249,17 +333,12 @@ export default function ExportDialog({ open, setOpen, searchQueryId }) { -
+
+ {cocoJobId && !loadingStates.cocoFormat && ( + + )}
From 70b301a684d7bfbb733b708f05b497b4bf57190d Mon Sep 17 00:00:00 2001 From: Wild Me Date: Sat, 11 Apr 2026 22:10:45 -0700 Subject: [PATCH 7/9] Allow browser resume --- .../export/EncounterSearchExportCOCO.java | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/ecocean/servlet/export/EncounterSearchExportCOCO.java b/src/main/java/org/ecocean/servlet/export/EncounterSearchExportCOCO.java index 46ea08b32c..62ba990e6c 100644 --- a/src/main/java/org/ecocean/servlet/export/EncounterSearchExportCOCO.java +++ b/src/main/java/org/ecocean/servlet/export/EncounterSearchExportCOCO.java @@ -18,6 +18,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.io.RandomAccessFile; import java.nio.file.Files; import java.util.ArrayList; import java.util.Iterator; @@ -201,13 +202,62 @@ private void handleDownload(HttpServletRequest request, HttpServletResponse resp } // Job stays in the map — retryable until the 1-hour purge removes it. - // This accommodates slow connections and failed downloads. + long fileLength = job.tempFile.length(); + long start = 0; + long end = fileLength - 1; + + // Support Range requests so browsers can resume interrupted downloads + // instead of restarting from the beginning. + String rangeHeader = request.getHeader("Range"); + if (rangeHeader != null && rangeHeader.startsWith("bytes=")) { + String rangeSpec = rangeHeader.substring(6).trim(); + String[] parts = rangeSpec.split("-", 2); + try { + if (!parts[0].isEmpty()) { + start = Long.parseLong(parts[0]); + } + if (parts.length > 1 && !parts[1].isEmpty()) { + end = Long.parseLong(parts[1]); + } + } catch (NumberFormatException e) { + response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); + response.setHeader("Content-Range", "bytes */" + fileLength); + return; + } + if (start < 0 || start > end || start >= fileLength) { + response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); + response.setHeader("Content-Range", "bytes */" + fileLength); + return; + } + if (end >= fileLength) { + end = fileLength - 1; + } + long contentLength = end - start + 1; + response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); + response.setHeader("Content-Range", + "bytes " + start + "-" + end + "/" + fileLength); + response.setContentLengthLong(contentLength); + } else { + response.setContentLengthLong(fileLength); + } + response.setContentType("application/zip"); response.setHeader("Content-Disposition", "attachment; filename=\"wildbook-coco-export.zip\""); - response.setContentLengthLong(job.tempFile.length()); + response.setHeader("Accept-Ranges", "bytes"); + OutputStream out = response.getOutputStream(); - Files.copy(job.tempFile.toPath(), out); + try (RandomAccessFile raf = new RandomAccessFile(job.tempFile, "r")) { + raf.seek(start); + byte[] buffer = new byte[65536]; + long remaining = end - start + 1; + int read; + while (remaining > 0 && (read = raf.read(buffer, 0, + (int) Math.min(buffer.length, remaining))) != -1) { + out.write(buffer, 0, read); + remaining -= read; + } + } out.flush(); } From 1b767b0ad516146803d40459aca7a9ad7c7f7b40 Mon Sep 17 00:00:00 2001 From: Wild Me Date: Sat, 11 Apr 2026 22:40:01 -0700 Subject: [PATCH 8/9] adapt for nginx reverse proxy --- .../export/EncounterSearchExportCOCO.java | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/ecocean/servlet/export/EncounterSearchExportCOCO.java b/src/main/java/org/ecocean/servlet/export/EncounterSearchExportCOCO.java index 62ba990e6c..30debce003 100644 --- a/src/main/java/org/ecocean/servlet/export/EncounterSearchExportCOCO.java +++ b/src/main/java/org/ecocean/servlet/export/EncounterSearchExportCOCO.java @@ -188,27 +188,34 @@ private void handleStatus(HttpServletRequest request, HttpServletResponse respon sendJson(response, 200, json.toString()); } + private static final java.util.logging.Logger log = + java.util.logging.Logger.getLogger(EncounterSearchExportCOCO.class.getName()); + private void handleDownload(HttpServletRequest request, HttpServletResponse response) throws IOException { String jobId = request.getParameter("jobId"); ExportJob job = (jobId != null) ? jobs.get(jobId) : null; if (job == null) { + log.warning("COCO Download: job not found, jobId=" + jobId); sendJson(response, 404, "{\"error\":\"Job not found\"}"); return; } if (!"complete".equals(job.status) || job.tempFile == null || !job.tempFile.exists()) { + log.warning("COCO Download: job not ready, jobId=" + jobId + + " status=" + job.status + " tempFile=" + job.tempFile + + " exists=" + (job.tempFile != null && job.tempFile.exists())); sendJson(response, 400, "{\"error\":\"Export not ready\"}"); return; } - // Job stays in the map — retryable until the 1-hour purge removes it. long fileLength = job.tempFile.length(); long start = 0; long end = fileLength - 1; - // Support Range requests so browsers can resume interrupted downloads - // instead of restarting from the beginning. String rangeHeader = request.getHeader("Range"); + log.info("COCO Download: jobId=" + jobId + " fileLength=" + fileLength + + " Range=" + rangeHeader + " method=" + request.getMethod()); + if (rangeHeader != null && rangeHeader.startsWith("bytes=")) { String rangeSpec = rangeHeader.substring(6).trim(); String[] parts = rangeSpec.split("-", 2); @@ -237,15 +244,24 @@ private void handleDownload(HttpServletRequest request, HttpServletResponse resp response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength); response.setContentLengthLong(contentLength); + log.info("COCO Download: sending 206, start=" + start + " end=" + end + + " contentLength=" + contentLength); } else { response.setContentLengthLong(fileLength); + log.info("COCO Download: sending 200, full file, length=" + fileLength); } response.setContentType("application/zip"); response.setHeader("Content-Disposition", "attachment; filename=\"wildbook-coco-export.zip\""); response.setHeader("Accept-Ranges", "bytes"); + // Tell nginx to stream directly to the client instead of buffering. + // Without this, nginx buffers up to proxy_max_temp_file_size (default 1GB), + // then stalls Tomcat's writes, eventually timing out and cutting the connection + // — causing browsers to restart the download in an infinite loop. + response.setHeader("X-Accel-Buffering", "no"); + long bytesSent = 0; OutputStream out = response.getOutputStream(); try (RandomAccessFile raf = new RandomAccessFile(job.tempFile, "r")) { raf.seek(start); @@ -256,9 +272,15 @@ private void handleDownload(HttpServletRequest request, HttpServletResponse resp (int) Math.min(buffer.length, remaining))) != -1) { out.write(buffer, 0, read); remaining -= read; + bytesSent += read; } + } catch (IOException e) { + log.warning("COCO Download: connection broken after " + bytesSent + + " bytes (of " + (end - start + 1) + " expected). " + e.getMessage()); + throw e; } out.flush(); + log.info("COCO Download: completed, sent " + bytesSent + " bytes for jobId=" + jobId); } /** Synchronous fallback for legacy/non-JS callers. */ From 8cd4f3713839fdb4097215950321ebb104d82af3 Mon Sep 17 00:00:00 2001 From: Wild Me Date: Sun, 12 Apr 2026 21:32:32 -0700 Subject: [PATCH 9/9] refine empty target annotations check --- src/main/java/org/ecocean/ia/Task.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/ecocean/ia/Task.java b/src/main/java/org/ecocean/ia/Task.java index d840c5a561..b38d4b7a78 100644 --- a/src/main/java/org/ecocean/ia/Task.java +++ b/src/main/java/org/ecocean/ia/Task.java @@ -502,6 +502,14 @@ public String getStatus(Shepherd myShepherd) { status = "completed"; } else if (logs.toString().indexOf("score") > -1) { status = "completed"; + } else if (islObj.optJSONObject("status") != null && + islObj.optJSONObject("status").optJSONObject("error") != null && + islObj.optJSONObject("status").optJSONObject("error").optBoolean( + "emptyTargetAnnotations", false)) { + // No target annotations to match against is a terminal state, not a failure. + // Treating it as completed lets import progress reach 100%. + System.out.println("[Task.getStatus] emptyTargetAnnotations detected for task " + getId() + ", marking completed"); + status = "completed"; } else if (islObj.toString().indexOf("HTTP error code") > -1) { status = "error"; } else if (!islObj.optString("queueStatus").equals("")) { @@ -511,7 +519,7 @@ public String getStatus(Shepherd myShepherd) { status = "queuing"; } // if(islObj.optString("queueStatus").equals("queued")){sendIdentify=false;} - // if(status.equals("waiting to queue"))System.out.println("islObj: "+islObj.toString()); + if(status.equals("waiting to queue"))System.out.println("[Task.getStatus DEBUG] waiting to queue, islObj: "+islObj.toString()); } return status; }