diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 50af99e601..3d71a334ea 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -511,6 +511,7 @@ "ROW": "Zeile", "APPLY_TO_ALL_ROWS_NOTE": "+ kennzeichnet, dass die Änderung auf alle Zeilen dieser Spalte angewendet werden kann", "BULK_IMPORT_DELETE_TASK": "Massenimport-Aufgabe löschen", + "BULK_IMPORT_DELETE_TASK_IN_PROGRESS": "Wird gelöscht...", "BULK_IMPORT_DATA_UPLOADED": "Daten hochgeladen", "BULK_IMPORT_IMAGE_UPLOADED": "Bild hochgeladen", "SPREADSHEET_UPLOADED_TITLE": "Tabellen-Datei hochgeladen: {fileName}", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index e2942a0460..1cbe499eaf 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -509,6 +509,7 @@ "ROW": "Row", "APPLY_TO_ALL_ROWS_NOTE": "+ denotes change can apply to all rows for this column", "BULK_IMPORT_DELETE_TASK": "Delete Import Task", + "BULK_IMPORT_DELETE_TASK_IN_PROGRESS": "Deleting...", "BULK_IMPORT_DATA_UPLOADED": "Data Uploaded", "BULK_IMPORT_IMAGE_UPLOADED": "Image Uploaded", "SPREADSHEET_UPLOADED_TITLE": "Spreadsheet Uploaded: {fileName}", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 78f88a12d2..804332b537 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -511,6 +511,7 @@ "ROW": "Fila", "APPLY_TO_ALL_ROWS_NOTE": "+ denota que el cambio se puede aplicar a todas las filas de esta columna", "BULK_IMPORT_DELETE_TASK": "Eliminar tarea de importación masiva", + "BULK_IMPORT_DELETE_TASK_IN_PROGRESS": "Eliminando...", "BULK_IMPORT_DATA_UPLOADED": "Datos subidos", "BULK_IMPORT_IMAGE_UPLOADED": "Imagen subida", "SPREADSHEET_UPLOADED_TITLE": "Hoja de cálculo subida: {fileName}", diff --git a/frontend/src/locale/fr.json b/frontend/src/locale/fr.json index f6cdb7970b..b45d791b8e 100644 --- a/frontend/src/locale/fr.json +++ b/frontend/src/locale/fr.json @@ -511,6 +511,7 @@ "ROW": "Ligne", "APPLY_TO_ALL_ROWS_NOTE": "+ indique que la modification peut s'appliquer à toutes les lignes de cette colonne", "BULK_IMPORT_DELETE_TASK": "Supprimer la tâche d'importation en masse", + "BULK_IMPORT_DELETE_TASK_IN_PROGRESS": "Suppression...", "BULK_IMPORT_DATA_UPLOADED": "Données téléchargées", "BULK_IMPORT_IMAGE_UPLOADED": "Image téléchargée", "SPREADSHEET_UPLOADED_TITLE": "Feuille de calcul téléchargée: {fileName}", diff --git a/frontend/src/locale/it.json b/frontend/src/locale/it.json index 9afee9807f..bf495567d0 100644 --- a/frontend/src/locale/it.json +++ b/frontend/src/locale/it.json @@ -511,6 +511,7 @@ "ROW": "Riga", "APPLY_TO_ALL_ROWS_NOTE": "+ indica che la modifica può essere applicata a tutte le righe di questa colonna", "BULK_IMPORT_DELETE_TASK": "Elimina attività di importazione", + "BULK_IMPORT_DELETE_TASK_IN_PROGRESS": "Eliminazione in corso...", "BULK_IMPORT_DATA_UPLOADED": "Dati caricati", "BULK_IMPORT_IMAGE_UPLOADED": "Immagine caricata", "SPREADSHEET_UPLOADED_TITLE": "Foglio di calcolo caricato: {fileName}", diff --git a/frontend/src/pages/BulkImport/BulkImportTask.jsx b/frontend/src/pages/BulkImport/BulkImportTask.jsx index 5ccab73160..d51448365f 100644 --- a/frontend/src/pages/BulkImport/BulkImportTask.jsx +++ b/frontend/src/pages/BulkImport/BulkImportTask.jsx @@ -35,6 +35,7 @@ const BulkImportTask = observer(() => { const [userRoles, setUserRoles] = useState(null); const store = useLocalObservable(() => new BulkImportTaskStore()); const [rowsPerPage, setRowsPerPage] = useState(10); + const [isDeleting, setIsDeleting] = useState(false); const previousLocationID = task?.matchingLocations || []; @@ -76,12 +77,14 @@ const BulkImportTask = observer(() => { const deleteTask = async () => { if (!task?.id) return; + if (isDeleting) return; const confirmed = window.confirm( intl.formatMessage({ id: "BULK_IMPORT_DELETE_TASK_CONFIRM" }), ); if (!confirmed) return; + setIsDeleting(true); try { const res = await fetch(`/api/v3/bulk-import/${task.id}`, { method: "DELETE", @@ -102,6 +105,7 @@ const BulkImportTask = observer(() => { { error: err.message || "" }, ), ); + setIsDeleting(false); } }; @@ -568,6 +572,7 @@ const BulkImportTask = observer(() => { { marginLeft: 0, marginTop: "1rem", marginBottom: "2rem", + opacity: isDeleting ? 0.7 : 1, + cursor: isDeleting ? "not-allowed" : "pointer", }} > - + {isDeleting ? ( + <> + + + + ) : ( + + )} diff --git a/src/main/java/org/ecocean/api/patch/EncounterPatchValidator.java b/src/main/java/org/ecocean/api/patch/EncounterPatchValidator.java index 540226db18..0b6a95ca20 100644 --- a/src/main/java/org/ecocean/api/patch/EncounterPatchValidator.java +++ b/src/main/java/org/ecocean/api/patch/EncounterPatchValidator.java @@ -248,7 +248,7 @@ public static JSONObject applyPatch(Encounter enc, JSONObject patch, User user, enc.addAnnotation(triv); myShepherd.getPM().makePersistent(triv); } - myShepherd.getPM().deletePersistent(ann); + myShepherd.throwAwayAnnotation(ann); value = ann; } else if (path.equals("occurrenceId")) { // this may be overkill. *technically* an Encounter should be contained in (at most) ONE Occurrence diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index c3ca6897ed..6f1825c80d 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -397,6 +397,10 @@ public int numberProspects() { return Util.collectionSize(prospects); } + public Set getCandidates() { + return candidates; + } + public Set prospectScoreTypes() { Set types = new HashSet(); diff --git a/src/main/java/org/ecocean/servlet/AnnotationEdit.java b/src/main/java/org/ecocean/servlet/AnnotationEdit.java index 24e327ca8e..fd9863a930 100644 --- a/src/main/java/org/ecocean/servlet/AnnotationEdit.java +++ b/src/main/java/org/ecocean/servlet/AnnotationEdit.java @@ -163,7 +163,7 @@ public class AnnotationEdit extends HttpServlet { myShepherd.getPM().deletePersistent(enc); rtn.put("encounterDeleted", true); } - myShepherd.getPM().deletePersistent(annot); + myShepherd.throwAwayAnnotation(annot); myShepherd.getPM().deletePersistent(feat); System.out.println( "INFO: AnnotationEdit.remove deleted [enc,annot,feat]=[" + diff --git a/src/main/java/org/ecocean/servlet/EncounterRemoveAnnotation.java b/src/main/java/org/ecocean/servlet/EncounterRemoveAnnotation.java index 8841fe0e4e..0ee4ccb92d 100644 --- a/src/main/java/org/ecocean/servlet/EncounterRemoveAnnotation.java +++ b/src/main/java/org/ecocean/servlet/EncounterRemoveAnnotation.java @@ -104,7 +104,7 @@ public void doPost(HttpServletRequest request, HttpServletResponse response) enc.addComments("

Annotation deleted by " + user.getDisplayName() + " on " + Util.prettyTimeStamp() + "

"); - myShepherd.getPM().deletePersistent(ann); + myShepherd.throwAwayAnnotation(ann); myShepherd.updateDBTransaction(); res.put("revertToTrivial", true); } @@ -121,7 +121,7 @@ else if (!ann.isTrivial()) { "\">Annotation deleted by " + user.getDisplayName() + " on " + Util.prettyTimeStamp() + "

"); enc.removeAnnotation(ann); - myShepherd.getPM().deletePersistent(ann); + myShepherd.throwAwayAnnotation(ann); myShepherd.commitDBTransaction(); } response.setStatus(HttpServletResponse.SC_OK); diff --git a/src/main/java/org/ecocean/servlet/importer/ImportTask.java b/src/main/java/org/ecocean/servlet/importer/ImportTask.java index 73906e0b10..6fded9d75b 100644 --- a/src/main/java/org/ecocean/servlet/importer/ImportTask.java +++ b/src/main/java/org/ecocean/servlet/importer/ImportTask.java @@ -498,44 +498,36 @@ public static void deleteWithRelated(String id, User user, Shepherd myShepherd) if (!user.isAdmin(myShepherd) && !Collaboration.canUserAccessImportTask(itask, myShepherd.getContext(), user.getUsername())) throw new IOException("user does not have privileges to delete task"); + long startedAt = System.currentTimeMillis(); Util.mark("ImportTask.deleteWithRelated(" + id + ") started"); try { List allEncs = new ArrayList(itask.getEncounters()); - int total = allEncs.size(); - for (int i = 0; i < allEncs.size(); i++) { - Encounter enc = allEncs.get(i); + // First pass: detach annotations from encounters and collect them for a single + // batched FK-cleanup at the end. The previous per-annotation throwAwayAnnotation + // ran ~6 unindexed JDOQL queries per annotation, which becomes catastrophic for + // large imports. The batch path collapses that to a constant number of queries. + List allAnns = new ArrayList(); + long phaseStart = System.currentTimeMillis(); + for (Encounter enc : allEncs) { Occurrence occ = myShepherd.getOccurrence(enc); MarkedIndividual mark = myShepherd.getMarkedIndividualQuiet(enc.getIndividualID()); List projects = myShepherd.getProjectsForEncounter(enc); ArrayList anns = enc.getAnnotations(); - for (Annotation ann : anns) { - enc.removeAnnotation(ann); - // myShepherd.updateDBTransaction(); - List iaTasks = Task.getTasksFor(ann, myShepherd); - if (iaTasks != null && !iaTasks.isEmpty()) { - for (Task iaTask : iaTasks) { - iaTask.removeObject(ann); - // myShepherd.updateDBTransaction(); - } + if (anns != null) { + for (Annotation ann : new ArrayList(anns)) { + enc.removeAnnotation(ann); + if (ann != null) allAnns.add(ann); } - myShepherd.throwAwayAnnotation(ann); - // myShepherd.updateDBTransaction(); } - // handle occurrences if (occ != null) { occ.removeEncounter(enc); - // myShepherd.updateDBTransaction(); if (occ.getEncounters().size() == 0) { myShepherd.throwAwayOccurrence(occ); - // myShepherd.updateDBTransaction(); } } - // handle markedindividual if (mark != null) { mark.removeEncounter(enc); - // myShepherd.updateDBTransaction(); if (mark.getEncounters().size() == 0) { - // remove scheduled tasks referencing this individual List mergeTasks = myShepherd.getAllIncompleteScheduledIndividualMerges(); if (mergeTasks != null) { @@ -546,44 +538,43 @@ public static void deleteWithRelated(String id, User user, Shepherd myShepherd) } } } - // check for social unit membership and remove List units = myShepherd.getAllSocialUnitsForMarkedIndividual( mark); if (units != null && units.size() > 0) { for (SocialUnit unit : units) { - boolean worked = unit.removeMember(mark, myShepherd); - // if (worked) myShepherd.updateDBTransaction(); + unit.removeMember(mark, myShepherd); } } myShepherd.throwAwayMarkedIndividual(mark); - // myShepherd.updateDBTransaction(); } } - // handle projects if (projects != null && projects.size() > 0) { for (Project project : projects) { project.removeEncounter(enc); - // myShepherd.updateDBTransaction(); } } itask.removeEncounter(enc); itask.addLog("Servlet DeleteImportTask removed Encounter: " + enc.getCatalogNumber()); - // myShepherd.updateDBTransaction(); try { myShepherd.throwAwayEncounter(enc); } catch (Exception e) { System.out.println("Exception on throwAwayEncounter!!"); e.printStackTrace(); } - // myShepherd.updateDBTransaction(); } + Util.mark("ImportTask.deleteWithRelated(" + id + ") encounter loop done (" + + allEncs.size() + " encs, " + allAnns.size() + " anns)", phaseStart); + // Single batched FK cleanup + delete for all annotations across the import. + phaseStart = System.currentTimeMillis(); + myShepherd.throwAwayAnnotations(allAnns); + Util.mark("ImportTask.deleteWithRelated(" + id + + ") batched annotation cleanup done", phaseStart); myShepherd.getPM().deletePersistent(itask); - // myShepherd.commitDBTransaction(); } catch (Exception ex) { throw new IOException("general exception on ImportTask delete: " + ex); } - Util.mark("ImportTask.deleteWithRelated(" + id + ") completed"); + Util.mark("ImportTask.deleteWithRelated(" + id + ") completed", startedAt); } // this is hobbled together from some complex code in import.jsp diff --git a/src/main/java/org/ecocean/shepherd/core/Shepherd.java b/src/main/java/org/ecocean/shepherd/core/Shepherd.java index 52b5a1fef6..d2481a5553 100644 --- a/src/main/java/org/ecocean/shepherd/core/Shepherd.java +++ b/src/main/java/org/ecocean/shepherd/core/Shepherd.java @@ -17,6 +17,7 @@ import org.ecocean.grid.ScanTask; import org.ecocean.grid.ScanWorkItem; import org.ecocean.ia.MatchResult; +import org.ecocean.ia.MatchResultProspect; import org.ecocean.ia.Task; import org.ecocean.media.*; import org.ecocean.movement.Path; @@ -467,8 +468,129 @@ public void throwAwayMicrosatelliteMarkersAnalysis(MicrosatelliteMarkersAnalysis pm.deletePersistent(analysis); } + // Caller must detach this Annotation from its owning Encounter (Encounter.removeAnnotation) + // before calling — that's a business decision about which encounter the annotation + // belongs to. This method handles the FK-constrained dependents that JDO mapping cascades + // do not reach: MatchResult, MatchResultProspect, Embedding, and the Task.objectAnnotations + // join collection. Without this cleanup, the JDO commit fails with constraints like + // MATCHRESULT_FK1 (MatchResult.queryAnnotation), the FKs on MatchResultProspect.annotation, + // EMBEDDING.ANNOTATION_ID, and the Task↔Annotation join table. public void throwAwayAnnotation(Annotation ad) { - pm.deletePersistent(ad); + if (ad == null) return; + throwAwayAnnotations(java.util.Collections.singletonList(ad)); + } + + // Batch version: collects all FK-bound dependents in a single pass per dependent table + // (one JDOQL query per table instead of N). Used by ImportTask.deleteWithRelated for + // bulk-import deletion which can otherwise be O(N) queries × seq scans of large IA tables. + public void throwAwayAnnotations(Collection anns) { + if (anns == null || anns.isEmpty()) return; + List annIds = new ArrayList(); + for (Annotation a : anns) { + if (a != null && a.getId() != null) annIds.add(a.getId()); + } + if (annIds.isEmpty()) return; + cleanUpAnnotationReferencesBatch(annIds); + pm.flush(); // ensure dependent deletes hit the DB before the annotation rows go + for (Annotation a : anns) { + if (a != null) pm.deletePersistent(a); + } + } + + private void cleanUpAnnotationReferencesBatch(Collection annIds) { + // 1. Delete every MatchResultProspect whose annotation OR whose parent MR's + // queryAnnotation is in the doomed set. Single query covers both cases. + Query qProspects = pm.newQuery(MatchResultProspect.class); + qProspects.setFilter( + ":ids.contains(annotation.id) || :ids.contains(matchResult.queryAnnotation.id)"); + try { + @SuppressWarnings("unchecked") + Collection prospects = + (Collection)qProspects.execute(annIds); + if (prospects != null) { + for (MatchResultProspect p : new ArrayList(prospects)) { + pm.deletePersistent(p); + } + } + } finally { + qProspects.closeAll(); + } + pm.flush(); // commit prospect deletes before MR FK check + + // 2. Delete MatchResults whose queryAnnotation is in the doomed set. + Query qMRs = pm.newQuery(MatchResult.class); + qMRs.setFilter(":ids.contains(queryAnnotation.id)"); + try { + @SuppressWarnings("unchecked") + Collection mrs = (Collection)qMRs.execute(annIds); + if (mrs != null) { + for (MatchResult mr : new ArrayList(mrs)) { + pm.deletePersistent(mr); + } + } + } finally { + qMRs.closeAll(); + } + pm.flush(); + + // 3. Remove all doomed annotations from any surviving MatchResult.candidates collection. + // Defensive — the field is rarely populated (see MatchResult.java comment) but the + // join-table FK would still block deletion if any row referenced these annotations. + Query qCand = pm.newQuery(MatchResult.class); + qCand.setFilter("candidates.contains(c) && :ids.contains(c.id)"); + qCand.declareVariables("org.ecocean.Annotation c"); + try { + @SuppressWarnings("unchecked") + Collection mrsWithCand = (Collection)qCand.execute(annIds); + if (mrsWithCand != null) { + for (MatchResult mr : new ArrayList(mrsWithCand)) { + if (mr.getCandidates() == null) continue; + Iterator it = mr.getCandidates().iterator(); + while (it.hasNext()) { + Annotation cAnn = it.next(); + if (cAnn != null && annIds.contains(cAnn.getId())) it.remove(); + } + } + } + } finally { + qCand.closeAll(); + } + + // 4. Delete Embeddings whose annotation is in the doomed set. + Query qEmb = pm.newQuery(Embedding.class); + qEmb.setFilter(":ids.contains(annotation.id)"); + try { + @SuppressWarnings("unchecked") + Collection embs = (Collection)qEmb.execute(annIds); + if (embs != null) { + for (Embedding e : new ArrayList(embs)) { + pm.deletePersistent(e); + } + } + } finally { + qEmb.closeAll(); + } + + // 5. Remove all doomed annotations from any Task.objectAnnotations join collection. + Query qTasks = pm.newQuery(Task.class); + qTasks.setFilter("objectAnnotations.contains(a) && :ids.contains(a.id)"); + qTasks.declareVariables("org.ecocean.Annotation a"); + try { + @SuppressWarnings("unchecked") + Collection tasks = (Collection)qTasks.execute(annIds); + if (tasks != null) { + for (Task t : new ArrayList(tasks)) { + if (t.getObjectAnnotations() == null) continue; + List toRemove = new ArrayList(); + for (Annotation a : t.getObjectAnnotations()) { + if (a != null && annIds.contains(a.getId())) toRemove.add(a); + } + for (Annotation a : toRemove) t.removeObject(a); + } + } + } finally { + qTasks.closeAll(); + } } public void throwAwayOccurrence(Occurrence occ) { diff --git a/src/main/resources/org/ecocean/ia/package.jdo b/src/main/resources/org/ecocean/ia/package.jdo index b47b49b153..9f75bf2c43 100755 --- a/src/main/resources/org/ecocean/ia/package.jdo +++ b/src/main/resources/org/ecocean/ia/package.jdo @@ -86,11 +86,12 @@ alter table "TASK" alter column "PARAMETERS" type text; - + + @@ -102,6 +103,7 @@ alter table "TASK" alter column "PARAMETERS" type text; + diff --git a/src/main/resources/org/ecocean/package.jdo b/src/main/resources/org/ecocean/package.jdo index 74519b8018..3e3fd08d94 100755 --- a/src/main/resources/org/ecocean/package.jdo +++ b/src/main/resources/org/ecocean/package.jdo @@ -1023,6 +1023,7 @@ +